Skip to content
This repository was archived by the owner on Jun 28, 2026. It is now read-only.

Commit 769b3b8

Browse files
Merge pull request #371 from olasunkanmi-SE/feature/team_graph_ui
feat(team-graph, webview-ui): Introduce team intelligence graph and a…
2 parents acc272e + d59f39d commit 769b3b8

13 files changed

Lines changed: 1996 additions & 277 deletions

File tree

docs/WEBVIEW_UI_AUDIT.md

Lines changed: 317 additions & 0 deletions
Large diffs are not rendered by default.

src/webview-providers/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
SessionHandler,
6969
SettingsHandler,
7070
StandupHandler,
71+
TeamGraphHandler,
7172
DoctorHandler,
7273
OnboardingHandler,
7374
} from "./handlers";
@@ -268,6 +269,7 @@ export abstract class BaseWebViewProvider implements vscode.Disposable {
268269
this.handlerRegistry.register(new CheckpointHandler());
269270
this.handlerRegistry.register(new ComposerHandler());
270271
this.handlerRegistry.register(new StandupHandler());
272+
this.handlerRegistry.register(new TeamGraphHandler());
271273
this.handlerRegistry.register(new DoctorHandler());
272274
this.handlerRegistry.register(new OnboardingHandler());
273275
this.handlerRegistry.register(

src/webview-providers/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ export { PerformanceHandler } from "./performance-handler";
1919
export { CheckpointHandler } from "./checkpoint-handler";
2020
export { ComposerHandler } from "./composer-handler";
2121
export { StandupHandler } from "./standup-handler";
22+
export { TeamGraphHandler } from "./team-graph-handler";
2223
export { DoctorHandler } from "./doctor-handler";
2324
export { OnboardingHandler } from "./onboarding-handler";
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import { WebviewMessageHandler, HandlerContext } from "./types";
2+
import { TeamGraphStore } from "../../services/team-graph-store";
3+
import type {
4+
PersonProfile,
5+
Relationship,
6+
} from "../../services/team-graph-store";
7+
8+
// ── Message types ──────────────────────────────────────────────
9+
10+
type TeamHydrateMessage = { command: "team-hydrate" };
11+
type TeamPersonProfileMessage = {
12+
command: "team-person-profile";
13+
name: string;
14+
};
15+
type TeamHealthMessage = { command: "team-health" };
16+
type TeamRelationshipsMessage = {
17+
command: "team-relationships";
18+
name?: string;
19+
};
20+
type TeamBlockersMessage = { command: "team-recurring-blockers" };
21+
type TeamCommitmentsMessage = {
22+
command: "team-commitments";
23+
name: string;
24+
};
25+
26+
type TeamGraphMessage =
27+
| TeamHydrateMessage
28+
| TeamPersonProfileMessage
29+
| TeamHealthMessage
30+
| TeamRelationshipsMessage
31+
| TeamBlockersMessage
32+
| TeamCommitmentsMessage;
33+
34+
const TEAM_GRAPH_COMMANDS = [
35+
"team-hydrate",
36+
"team-person-profile",
37+
"team-health",
38+
"team-relationships",
39+
"team-recurring-blockers",
40+
"team-commitments",
41+
] as const;
42+
43+
function isTeamGraphMessage(msg: unknown): msg is TeamGraphMessage {
44+
return (
45+
typeof msg === "object" &&
46+
msg !== null &&
47+
"command" in msg &&
48+
typeof (msg as Record<string, unknown>).command === "string" &&
49+
TEAM_GRAPH_COMMANDS.includes(
50+
(msg as Record<string, unknown>)
51+
.command as (typeof TEAM_GRAPH_COMMANDS)[number],
52+
)
53+
);
54+
}
55+
56+
/** Serializable person for the webview. */
57+
interface TeamMember {
58+
id: number;
59+
name: string;
60+
role: string | null;
61+
expertise: string[];
62+
workStyle: string | null;
63+
standupCount: number;
64+
commitmentCount: number;
65+
completionCount: number;
66+
completionRate: number;
67+
firstSeen: string;
68+
lastSeen: string;
69+
}
70+
71+
interface TeamRelationshipEdge {
72+
sourceName: string;
73+
targetName: string;
74+
kind: string;
75+
weight: number;
76+
}
77+
78+
function personToMember(p: PersonProfile): TeamMember {
79+
return {
80+
id: p.id,
81+
name: p.name,
82+
role: p.role,
83+
expertise: p.traits.expertise ?? [],
84+
workStyle: p.traits.workStyle ?? null,
85+
standupCount: p.standup_count,
86+
commitmentCount: p.commitment_count,
87+
completionCount: p.completion_count,
88+
completionRate:
89+
p.commitment_count > 0
90+
? Math.round((p.completion_count / p.commitment_count) * 100)
91+
: 0,
92+
firstSeen: p.first_seen,
93+
lastSeen: p.last_seen,
94+
};
95+
}
96+
97+
export class TeamGraphHandler implements WebviewMessageHandler {
98+
readonly commands = [...TEAM_GRAPH_COMMANDS];
99+
100+
async handle(message: unknown, ctx: HandlerContext): Promise<void> {
101+
if (!isTeamGraphMessage(message)) {
102+
ctx.logger.warn("TeamGraphHandler received invalid message shape");
103+
return;
104+
}
105+
106+
try {
107+
const store = TeamGraphStore.getInstance();
108+
await store.ensureInitialized();
109+
110+
switch (message.command) {
111+
case "team-hydrate": {
112+
const people = store.getAllPeople();
113+
const members = people.map(personToMember);
114+
115+
// Get all collaboration relationships for the graph
116+
const edges: TeamRelationshipEdge[] = [];
117+
const seenEdges = new Set<string>();
118+
for (const person of people) {
119+
const rels = store.getRelationshipsFor(person.id);
120+
for (const rel of rels) {
121+
const key = [
122+
Math.min(rel.source_person_id, rel.target_person_id),
123+
Math.max(rel.source_person_id, rel.target_person_id),
124+
rel.kind,
125+
].join("-");
126+
if (seenEdges.has(key)) continue;
127+
seenEdges.add(key);
128+
129+
const src = people.find((p) => p.id === rel.source_person_id);
130+
const tgt = people.find((p) => p.id === rel.target_person_id);
131+
if (src && tgt) {
132+
edges.push({
133+
sourceName: src.name,
134+
targetName: tgt.name,
135+
kind: rel.kind,
136+
weight: rel.weight,
137+
});
138+
}
139+
}
140+
}
141+
142+
// Health stats — send structured data alongside markdown
143+
const healthMarkdown = store.getTeamHealth();
144+
const totalCommitments = people.reduce(
145+
(s, p) => s + p.commitment_count,
146+
0,
147+
);
148+
const totalCompleted = people.reduce(
149+
(s, p) => s + p.completion_count,
150+
0,
151+
);
152+
const summaries = store.getRecentSummaries(10000);
153+
154+
await ctx.webview.webview.postMessage({
155+
command: "team-hydrate-result",
156+
members,
157+
edges,
158+
health: healthMarkdown,
159+
healthStats: {
160+
teamSize: members.length,
161+
standups: summaries.length,
162+
avgCompletion:
163+
totalCommitments > 0
164+
? Math.round((totalCompleted / totalCommitments) * 100)
165+
: 0,
166+
totalBlockers: summaries.reduce(
167+
(s, su) => s + su.blockerCount,
168+
0,
169+
),
170+
},
171+
});
172+
break;
173+
}
174+
175+
case "team-person-profile": {
176+
if (!message.name || typeof message.name !== "string") {
177+
await ctx.webview.webview.postMessage({
178+
command: "team-person-profile-result",
179+
error: "No person name provided.",
180+
});
181+
return;
182+
}
183+
const profileMarkdown = store.getPersonProfile(message.name);
184+
const person = store.getPersonByName(message.name);
185+
const member = person ? personToMember(person) : null;
186+
187+
// Get commitments
188+
let commitments: Array<{
189+
action: string;
190+
status: string;
191+
date: string;
192+
}> = [];
193+
if (person) {
194+
commitments = store.getCommitmentsFor(person.id, 10).map((c) => ({
195+
action: c.action,
196+
status: c.status,
197+
date: c.date,
198+
}));
199+
}
200+
201+
// Get collaborators
202+
let collaborators: Array<{ name: string; weight: number }> = [];
203+
if (person) {
204+
collaborators = store
205+
.getTopCollaborators(person.id, 5)
206+
.map((c) => ({
207+
name: c.person.name,
208+
weight: c.weight,
209+
}));
210+
}
211+
212+
await ctx.webview.webview.postMessage({
213+
command: "team-person-profile-result",
214+
member,
215+
commitments,
216+
collaborators,
217+
profileMarkdown,
218+
});
219+
break;
220+
}
221+
222+
case "team-health": {
223+
const healthMarkdown = store.getTeamHealth();
224+
await ctx.webview.webview.postMessage({
225+
command: "team-health-result",
226+
health: healthMarkdown,
227+
});
228+
break;
229+
}
230+
231+
case "team-relationships": {
232+
const people = store.getAllPeople();
233+
const edges: TeamRelationshipEdge[] = [];
234+
const seenEdges = new Set<string>();
235+
236+
// If a specific person requested, only their relationships
237+
const targetPeople = message.name
238+
? people.filter(
239+
(p) => p.name.toLowerCase() === message.name!.toLowerCase(),
240+
)
241+
: people;
242+
243+
for (const person of targetPeople) {
244+
const rels = store.getRelationshipsFor(person.id);
245+
for (const rel of rels) {
246+
const key = [
247+
Math.min(rel.source_person_id, rel.target_person_id),
248+
Math.max(rel.source_person_id, rel.target_person_id),
249+
rel.kind,
250+
].join("-");
251+
if (seenEdges.has(key)) continue;
252+
seenEdges.add(key);
253+
254+
const src = people.find((p) => p.id === rel.source_person_id);
255+
const tgt = people.find((p) => p.id === rel.target_person_id);
256+
if (src && tgt) {
257+
edges.push({
258+
sourceName: src.name,
259+
targetName: tgt.name,
260+
kind: rel.kind,
261+
weight: rel.weight,
262+
});
263+
}
264+
}
265+
}
266+
267+
await ctx.webview.webview.postMessage({
268+
command: "team-relationships-result",
269+
edges,
270+
});
271+
break;
272+
}
273+
274+
case "team-recurring-blockers": {
275+
const blockersMarkdown = store.getRecurringBlockers();
276+
await ctx.webview.webview.postMessage({
277+
command: "team-recurring-blockers-result",
278+
blockers: blockersMarkdown,
279+
});
280+
break;
281+
}
282+
283+
case "team-commitments": {
284+
if (!message.name || typeof message.name !== "string") {
285+
await ctx.webview.webview.postMessage({
286+
command: "team-commitments-result",
287+
error: "No person name provided.",
288+
});
289+
return;
290+
}
291+
const person = store.getPersonByName(message.name);
292+
if (!person) {
293+
await ctx.webview.webview.postMessage({
294+
command: "team-commitments-result",
295+
error: `No person found: "${message.name}"`,
296+
});
297+
return;
298+
}
299+
const commitments = store
300+
.getCommitmentsFor(person.id, 20)
301+
.map((c) => ({
302+
action: c.action,
303+
status: c.status,
304+
date: c.date,
305+
deadline: c.deadline,
306+
}));
307+
308+
await ctx.webview.webview.postMessage({
309+
command: "team-commitments-result",
310+
name: person.name,
311+
commitments,
312+
});
313+
break;
314+
}
315+
}
316+
} catch (error: unknown) {
317+
const msg = error instanceof Error ? error.message : "Unknown error";
318+
ctx.logger.error(`TeamGraphHandler error: ${msg}`);
319+
try {
320+
await ctx.webview.webview.postMessage({
321+
command: "team-error",
322+
error: msg,
323+
});
324+
} catch {
325+
// webview may be disposed
326+
}
327+
}
328+
}
329+
}

0 commit comments

Comments
 (0)