@hienlh/ppm 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/web/assets/{browser-tab-CpltAQ9R.js → browser-tab-CWkYQN8G.js} +1 -1
  3. package/dist/web/assets/chat-tab-BVf4q-TX.js +8 -0
  4. package/dist/web/assets/code-editor-r5T7wq0I.js +2 -0
  5. package/dist/web/assets/{database-viewer-DUDwfC9o.js → database-viewer-DIKCOXIJ.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-C8wC1442.js → diff-viewer-Cs2knua9.js} +1 -1
  7. package/dist/web/assets/{extension-webview-jl1C2HGm.js → extension-webview-BbDesnaR.js} +1 -1
  8. package/dist/web/assets/{git-graph-BgpRKeIW.js → git-graph-BWQm8I8V.js} +1 -1
  9. package/dist/web/assets/index-D-NC2Rnz.css +2 -0
  10. package/dist/web/assets/index-E0N-qark.js +37 -0
  11. package/dist/web/assets/keybindings-store-DXucgQSx.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-fLOZi3vK.js → markdown-renderer-DgeFkhU6.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-DhEmO5Pd.js → postgres-viewer-hQ47YDO5.js} +1 -1
  14. package/dist/web/assets/{settings-tab-B0JPmP_y.js → settings-tab-BZ_CLZDj.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-4YwpO6X6.js → sqlite-viewer-B8vs7svK.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-Dc1b9QaT.js → terminal-tab-DjE8lCXj.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-CN0etsaO.js → use-monaco-theme-BNtfLGmz.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/project-changelog.md +8 -0
  21. package/docs/project-roadmap.md +2 -1
  22. package/package.json +1 -1
  23. package/src/index.ts +1 -0
  24. package/src/server/index.ts +4 -0
  25. package/src/server/routes/git.ts +56 -0
  26. package/src/server/routes/teams.ts +40 -0
  27. package/src/server/ws/chat.ts +42 -0
  28. package/src/server/ws/team-inbox-watcher.ts +181 -0
  29. package/src/services/git.service.ts +99 -0
  30. package/src/types/chat.ts +4 -1
  31. package/src/types/git.ts +21 -0
  32. package/src/types/team.ts +33 -0
  33. package/src/web/components/chat/chat-tab.tsx +6 -0
  34. package/src/web/components/chat/message-input.tsx +70 -1
  35. package/src/web/components/chat/team-activity-popover.tsx +202 -0
  36. package/src/web/components/git/create-worktree-dialog.tsx +232 -0
  37. package/src/web/components/git/git-status-panel.tsx +13 -0
  38. package/src/web/components/git/git-worktree-panel.tsx +306 -0
  39. package/src/web/components/layout/draggable-tab.tsx +7 -1
  40. package/src/web/components/layout/editor-panel.tsx +1 -1
  41. package/src/web/components/layout/split-drop-overlay.tsx +10 -5
  42. package/src/web/components/layout/tab-bar.tsx +6 -0
  43. package/src/web/components/settings/ai-settings-section.tsx +83 -1
  44. package/src/web/hooks/use-chat.ts +96 -1
  45. package/src/web/hooks/use-media-query.ts +18 -0
  46. package/src/web/hooks/use-tab-drag.ts +1 -1
  47. package/src/web/hooks/use-touch-tab-drag.ts +190 -0
  48. package/dist/web/assets/chat-tab-BDvg70wQ.js +0 -8
  49. package/dist/web/assets/code-editor-CQXdf1v_.js +0 -2
  50. package/dist/web/assets/index-BPTNjFbl.js +0 -37
  51. package/dist/web/assets/index-D3XyoeN3.css +0 -2
  52. package/dist/web/assets/keybindings-store-BTCdVItE.js +0 -1
@@ -0,0 +1,40 @@
1
+ import { Hono } from "hono";
2
+ import { ok, err } from "../../types/api.ts";
3
+ import { listTeams, readTeamDetail } from "../ws/team-inbox-watcher.ts";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ import { rm } from "fs/promises";
7
+
8
+ /** Allowlist: team names must be alphanumeric with hyphens/underscores only */
9
+ const VALID_TEAM_NAME = /^[a-zA-Z0-9_-]+$/;
10
+
11
+ export const teamRoutes = new Hono();
12
+
13
+ teamRoutes.get("/", async (c) => {
14
+ const teams = await listTeams();
15
+ return c.json(ok(teams));
16
+ });
17
+
18
+ teamRoutes.get("/:name", async (c) => {
19
+ const name = c.req.param("name");
20
+ if (!VALID_TEAM_NAME.test(name)) {
21
+ return c.json(err("Invalid team name"), 400);
22
+ }
23
+ const detail = await readTeamDetail(name);
24
+ if (!detail) return c.json(err("Team not found"), 404);
25
+ return c.json(ok(detail));
26
+ });
27
+
28
+ teamRoutes.delete("/:name", async (c) => {
29
+ const name = c.req.param("name");
30
+ if (!VALID_TEAM_NAME.test(name)) {
31
+ return c.json(err("Invalid team name"), 400);
32
+ }
33
+ const teamDir = join(homedir(), ".claude", "teams", name);
34
+ try {
35
+ await rm(teamDir, { recursive: true, force: true });
36
+ return c.json(ok({ deleted: name }));
37
+ } catch (e) {
38
+ return c.json(err(`Failed to delete: ${(e as Error).message}`), 500);
39
+ }
40
+ });
@@ -12,6 +12,7 @@ const MAX_TURN_EVENTS = 10_000; // memory safety cap
12
12
  const BUFFERABLE_TYPES = new Set([
13
13
  "text", "thinking", "tool_use", "tool_result",
14
14
  "approval_request", "error", "done", "account_info", "account_retry",
15
+ "team_detected",
15
16
  ]);
16
17
 
17
18
  type ChatWsSocket = {
@@ -34,6 +35,12 @@ interface SessionEntry {
34
35
  permissionMode?: string;
35
36
  /** Whether the persistent event consumer loop is running */
36
37
  isStreamingActive: boolean;
38
+ /** Active team watchers keyed by team name */
39
+ teamWatchers: Map<string, { cleanup: () => void }>;
40
+ /** Set of detected team names for this session */
41
+ teamNames: Set<string>;
42
+ /** toolUseId of a pending TeamCreate call */
43
+ pendingTeamCreate?: string;
37
44
  }
38
45
 
39
46
  /** Tracks active sessions — persists even when FE disconnects */
@@ -133,6 +140,8 @@ function startCleanupTimer(sessionId: string): void {
133
140
  logSessionEvent(sessionId, "INFO", "Session cleaned up (idle, no FE reconnected)");
134
141
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
135
142
  entry.pingIntervals.clear();
143
+ for (const w of entry.teamWatchers.values()) w.cleanup();
144
+ entry.teamWatchers.clear();
136
145
  activeSessions.delete(sessionId);
137
146
  }, CLEANUP_TIMEOUT_MS);
138
147
  }
@@ -254,8 +263,35 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
254
263
  logSessionEvent(sessionId, "TEXT", ev.content?.slice(0, 500) ?? "");
255
264
  } else if (evType === "tool_use") {
256
265
  logSessionEvent(sessionId, "TOOL_USE", `${ev.tool} ${JSON.stringify(ev.input).slice(0, 300)}`);
266
+ // Track TeamCreate calls for team detection
267
+ if (ev.tool === "TeamCreate") {
268
+ entry.pendingTeamCreate = ev.toolUseId;
269
+ console.log(`[chat] session=${sessionId} TeamCreate tool_use detected, toolUseId=${ev.toolUseId}`);
270
+ }
257
271
  } else if (evType === "tool_result") {
258
272
  logSessionEvent(sessionId, "TOOL_RESULT", `error=${ev.isError ?? false} ${(ev.output ?? "").slice(0, 300)}`);
273
+ console.log(`[chat] session=${sessionId} tool_result: toolUseId=${ev.toolUseId} pendingTeamCreate=${entry.pendingTeamCreate} output=${(ev.output ?? "").slice(0, 200)}`);
274
+ // Detect team creation from TeamCreate tool_result
275
+ if (entry.pendingTeamCreate && entry.pendingTeamCreate === ev.toolUseId) {
276
+ const { extractTeamName, startTeamInboxWatcher } = await import("./team-inbox-watcher.ts");
277
+ const teamName = extractTeamName(ev.output ?? "");
278
+ console.log(`[chat] session=${sessionId} TeamCreate result matched, extracted teamName=${teamName}`);
279
+ if (teamName && !entry.teamNames.has(teamName)) {
280
+ entry.teamNames.add(teamName);
281
+ const watcher = startTeamInboxWatcher(teamName, {
282
+ onInboxUpdate: (tn, agent, msgs) => broadcast(sessionId, {
283
+ type: "team_inbox", teamName: tn, agent, messages: msgs,
284
+ }),
285
+ onConfigUpdate: (tn, config) => broadcast(sessionId, {
286
+ type: "team_updated", teamName: tn, team: config,
287
+ }),
288
+ });
289
+ entry.teamWatchers.set(teamName, watcher);
290
+ bufferAndBroadcast(sessionId, { type: "team_detected", teamName });
291
+ console.log(`[chat] session=${sessionId} team detected: ${teamName}`);
292
+ }
293
+ entry.pendingTeamCreate = undefined;
294
+ }
259
295
  } else if (evType === "error") {
260
296
  const errorDetail = ev.message ?? JSON.stringify(ev).slice(0, 500);
261
297
  console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
@@ -333,6 +369,9 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
333
369
  entry.turnEvents = [];
334
370
  setPhase(sessionId, "idle");
335
371
  entry.pendingApprovalEvent = undefined;
372
+ // Cleanup team watchers
373
+ for (const w of entry.teamWatchers.values()) w.cleanup();
374
+ entry.teamWatchers.clear();
336
375
  // Close streaming session in provider
337
376
  const provider = providerRegistry.get(entry.providerId);
338
377
  if (provider && "closeStreamingSession" in provider) {
@@ -419,6 +458,8 @@ export const chatWebSocket = {
419
458
  phase: "idle",
420
459
  turnEvents: [],
421
460
  isStreamingActive: false,
461
+ teamWatchers: new Map(),
462
+ teamNames: new Set(),
422
463
  };
423
464
  activeSessions.set(sessionId, newEntry);
424
465
  setupClientPing(newEntry, ws);
@@ -470,6 +511,7 @@ export const chatWebSocket = {
470
511
  const newEntry: SessionEntry = {
471
512
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
472
513
  pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
514
+ teamWatchers: new Map(), teamNames: new Set(),
473
515
  };
474
516
  activeSessions.set(sessionId, newEntry);
475
517
  setupClientPing(newEntry, ws);
@@ -0,0 +1,181 @@
1
+ import { watch, type FSWatcher } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { readdir } from "fs/promises";
5
+
6
+ const TEAMS_DIR = join(homedir(), ".claude", "teams");
7
+ const DEBOUNCE_MS = 200;
8
+
9
+ /** Infer message type from JSON text field */
10
+ function inferMessageType(text: string): string {
11
+ try {
12
+ const parsed = JSON.parse(text);
13
+ return parsed?.type ?? "message";
14
+ } catch {
15
+ return "message";
16
+ }
17
+ }
18
+
19
+ interface WatcherCallbacks {
20
+ onInboxUpdate: (teamName: string, agent: string, messages: unknown[]) => void;
21
+ onConfigUpdate: (teamName: string, config: unknown) => void;
22
+ }
23
+
24
+ /** Start watching a team's inboxes directory + config.json for changes */
25
+ export function startTeamInboxWatcher(
26
+ teamName: string,
27
+ callbacks: WatcherCallbacks,
28
+ ): { watchers: FSWatcher[]; cleanup: () => void } {
29
+ const inboxDir = join(TEAMS_DIR, teamName, "inboxes");
30
+ const configPath = join(TEAMS_DIR, teamName, "config.json");
31
+ const watchers: FSWatcher[] = [];
32
+ const inboxSnapshots = new Map<string, number>(); // filename → last known msg count
33
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
34
+
35
+ // Watch inboxes directory
36
+ try {
37
+ const inboxWatcher = watch(inboxDir, (_event, filename) => {
38
+ if (!filename?.endsWith(".json")) return;
39
+
40
+ // Debounce per file
41
+ const existing = debounceTimers.get(filename);
42
+ if (existing) clearTimeout(existing);
43
+ debounceTimers.set(filename, setTimeout(async () => {
44
+ debounceTimers.delete(filename);
45
+ try {
46
+ const content = await Bun.file(join(inboxDir, filename)).text();
47
+ const messages = JSON.parse(content);
48
+ const agentName = filename.replace(".json", "");
49
+ const lastKnown = inboxSnapshots.get(filename) ?? 0;
50
+
51
+ if (Array.isArray(messages) && messages.length > lastKnown) {
52
+ const newMessages = messages.slice(lastKnown).map((m: any) => ({
53
+ ...m,
54
+ to: agentName,
55
+ parsedType: inferMessageType(m.text ?? ""),
56
+ }));
57
+ inboxSnapshots.set(filename, messages.length);
58
+ callbacks.onInboxUpdate(teamName, agentName, newMessages);
59
+ }
60
+ } catch { /* file mid-write or deleted */ }
61
+ }, DEBOUNCE_MS));
62
+ });
63
+ watchers.push(inboxWatcher);
64
+ } catch { /* inboxes dir may not exist yet */ }
65
+
66
+ // Watch config.json
67
+ try {
68
+ const configWatcher = watch(configPath, async () => {
69
+ try {
70
+ const content = await Bun.file(configPath).text();
71
+ callbacks.onConfigUpdate(teamName, JSON.parse(content));
72
+ } catch { /* mid-write */ }
73
+ });
74
+ watchers.push(configWatcher);
75
+ } catch { /* config may not exist */ }
76
+
77
+ return {
78
+ watchers,
79
+ cleanup: () => {
80
+ for (const w of watchers) w.close();
81
+ for (const t of debounceTimers.values()) clearTimeout(t);
82
+ debounceTimers.clear();
83
+ },
84
+ };
85
+ }
86
+
87
+ /** Read team config from filesystem */
88
+ export async function readTeamConfig(teamName: string): Promise<unknown | null> {
89
+ try {
90
+ const content = await Bun.file(join(TEAMS_DIR, teamName, "config.json")).text();
91
+ return JSON.parse(content);
92
+ } catch { return null; }
93
+ }
94
+
95
+ /** List all teams from ~/.claude/teams/ */
96
+ export async function listTeams(): Promise<unknown[]> {
97
+ try {
98
+ const entries = await readdir(TEAMS_DIR, { withFileTypes: true });
99
+ const teams = [];
100
+ for (const entry of entries) {
101
+ if (!entry.isDirectory()) continue;
102
+ const config = await readTeamConfig(entry.name);
103
+ if (config) teams.push(config);
104
+ }
105
+ return teams;
106
+ } catch { return []; }
107
+ }
108
+
109
+ /** Read team detail with merged inbox messages + inferred member status */
110
+ export async function readTeamDetail(teamName: string): Promise<unknown | null> {
111
+ const config = await readTeamConfig(teamName) as any;
112
+ if (!config) return null;
113
+
114
+ const inboxDir = join(TEAMS_DIR, teamName, "inboxes");
115
+ const messages: unknown[] = [];
116
+ let inboxFiles: string[] = [];
117
+ try {
118
+ inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith(".json"));
119
+ for (const file of inboxFiles) {
120
+ const content = await Bun.file(join(inboxDir, file)).text();
121
+ const agentName = file.replace(".json", "");
122
+ const parsed = JSON.parse(content);
123
+ if (Array.isArray(parsed)) {
124
+ messages.push(...parsed.map((m: any) => ({
125
+ ...m,
126
+ to: agentName,
127
+ parsedType: inferMessageType(m.text ?? ""),
128
+ })));
129
+ }
130
+ }
131
+ } catch { /* no inboxes dir */ }
132
+
133
+ // Sort by timestamp
134
+ messages.sort((a: any, b: any) =>
135
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
136
+ );
137
+
138
+ // Infer member status from inboxes
139
+ const members = (config.members ?? []).map((m: any) => ({
140
+ ...m,
141
+ status: inferMemberStatus(messages, m.name),
142
+ }));
143
+
144
+ // Discover additional members from inbox filenames (reuse already-read list)
145
+ for (const file of inboxFiles) {
146
+ const name = file.replace(".json", "");
147
+ if (!members.some((m: any) => m.name === name)) {
148
+ members.push({
149
+ name,
150
+ agentId: `${name}@${teamName}`,
151
+ agentType: "teammate",
152
+ model: "unknown",
153
+ status: inferMemberStatus(messages, name),
154
+ });
155
+ }
156
+ }
157
+
158
+ return { ...config, members, messages, memberCount: members.length };
159
+ }
160
+
161
+ function inferMemberStatus(messages: unknown[], agentName: string): string {
162
+ const fromAgent = (messages as any[]).filter(m => m.from === agentName).reverse();
163
+ if (fromAgent.length === 0) return "active";
164
+ const last = fromAgent[0];
165
+ const type = last.parsedType ?? inferMessageType(last.text ?? "");
166
+ if (type === "shutdown_approved") return "shutdown";
167
+ if (type === "idle_notification") return "idle";
168
+ return "active";
169
+ }
170
+
171
+ /** Extract team name from TeamCreate tool_result output */
172
+ export function extractTeamName(output: string): string | null {
173
+ try {
174
+ const parsed = JSON.parse(output);
175
+ return parsed?.team_name ?? parsed?.name ?? null;
176
+ } catch {
177
+ // Try regex: look for team_name in text
178
+ const match = output.match(/"team_name"\s*:\s*"([^"]+)"/);
179
+ return match?.[1] ?? null;
180
+ }
181
+ }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import simpleGit, { type SimpleGit } from "simple-git";
2
3
  import type {
3
4
  GitStatus,
@@ -5,6 +6,7 @@ import type {
5
6
  GitCommit,
6
7
  GitBranch,
7
8
  GitGraphData,
9
+ GitWorktree,
8
10
  } from "../types/git.ts";
9
11
 
10
12
  class GitService {
@@ -398,6 +400,103 @@ class GitService {
398
400
  }
399
401
  }
400
402
 
403
+ // ---------------------------------------------------------------------------
404
+ // Worktree operations
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /** Parse `git worktree list --porcelain -v` output into GitWorktree[]. */
408
+ async listWorktrees(projectPath: string): Promise<GitWorktree[]> {
409
+ const git = this.git(projectPath);
410
+ const raw = await git.raw(["worktree", "list", "--porcelain", "-v"]);
411
+ const worktrees: GitWorktree[] = [];
412
+ // Blocks are separated by blank lines
413
+ const blocks = raw.trim().split(/\n\n+/);
414
+ for (let i = 0; i < blocks.length; i++) {
415
+ const block = blocks[i]!;
416
+ const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
417
+ if (!lines.length) continue;
418
+ const wt: GitWorktree = {
419
+ path: "",
420
+ branch: "",
421
+ head: "",
422
+ isMain: i === 0,
423
+ isBare: false,
424
+ isDetached: false,
425
+ locked: false,
426
+ prunable: false,
427
+ };
428
+ for (const line of lines) {
429
+ if (line.startsWith("worktree ")) {
430
+ wt.path = line.slice("worktree ".length);
431
+ } else if (line.startsWith("HEAD ")) {
432
+ wt.head = line.slice("HEAD ".length);
433
+ } else if (line.startsWith("branch ")) {
434
+ // refs/heads/main → main
435
+ const ref = line.slice("branch ".length);
436
+ wt.branch = ref.replace(/^refs\/heads\//, "");
437
+ } else if (line === "bare") {
438
+ wt.isBare = true;
439
+ } else if (line === "detached") {
440
+ wt.isDetached = true;
441
+ } else if (line.startsWith("locked")) {
442
+ wt.locked = true;
443
+ const reason = line.slice("locked".length).trim();
444
+ if (reason) wt.lockReason = reason;
445
+ } else if (line.startsWith("prunable")) {
446
+ wt.prunable = true;
447
+ }
448
+ }
449
+ if (wt.path) worktrees.push(wt);
450
+ }
451
+ return worktrees;
452
+ }
453
+
454
+ /**
455
+ * Validate that targetPath is safe: must be under the parent directory of
456
+ * projectPath and must not contain path traversal sequences.
457
+ */
458
+ private validateWorktreePath(projectPath: string, targetPath: string): void {
459
+ const resolvedTarget = path.resolve(targetPath);
460
+ const parentDir = path.dirname(path.resolve(projectPath));
461
+ if (!resolvedTarget.startsWith(parentDir + path.sep) && resolvedTarget !== parentDir) {
462
+ throw new Error(`Worktree path must be within: ${parentDir}`);
463
+ }
464
+ if (resolvedTarget.includes("..")) {
465
+ throw new Error("Worktree path must not contain '..'");
466
+ }
467
+ }
468
+
469
+ /** Create a new worktree. */
470
+ async addWorktree(
471
+ projectPath: string,
472
+ targetPath: string,
473
+ opts: { branch?: string; newBranch?: string } = {},
474
+ ): Promise<void> {
475
+ this.validateWorktreePath(projectPath, targetPath);
476
+ const args = ["worktree", "add"];
477
+ if (opts.newBranch) {
478
+ args.push("-b", opts.newBranch);
479
+ }
480
+ args.push(targetPath);
481
+ if (opts.branch) {
482
+ args.push(opts.branch);
483
+ }
484
+ await this.git(projectPath).raw(args);
485
+ }
486
+
487
+ /** Remove a worktree. Pass force=true to remove even with uncommitted changes. */
488
+ async removeWorktree(projectPath: string, targetPath: string, force = false): Promise<void> {
489
+ const args = ["worktree", "remove"];
490
+ if (force) args.push("-f");
491
+ args.push(targetPath);
492
+ await this.git(projectPath).raw(args);
493
+ }
494
+
495
+ /** Prune stale worktree metadata from .git/worktrees/. */
496
+ async pruneWorktrees(projectPath: string): Promise<void> {
497
+ await this.git(projectPath).raw(["worktree", "prune"]);
498
+ }
499
+
401
500
  private parseRemoteUrl(
402
501
  url: string,
403
502
  ): { host: string; owner: string; repo: string } | null {
package/src/types/chat.ts CHANGED
@@ -115,7 +115,10 @@ export type ChatEvent =
115
115
  | { type: "session_migrated"; oldSessionId: string; newSessionId: string }
116
116
  | { type: "account_info"; accountId: string; accountLabel: string }
117
117
  | { type: "account_retry"; reason: string; accountId?: string; accountLabel?: string }
118
- | { type: "system"; subtype: string };
118
+ | { type: "system"; subtype: string }
119
+ | { type: "team_detected"; teamName: string }
120
+ | { type: "team_updated"; teamName: string; team: unknown }
121
+ | { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] };
119
122
 
120
123
  export type ToolApprovalHandler = (
121
124
  tool: string,
package/src/types/git.ts CHANGED
@@ -52,3 +52,24 @@ export interface GitDiffFile {
52
52
  deletions: number;
53
53
  content: string;
54
54
  }
55
+
56
+ export interface GitWorktree {
57
+ /** Absolute path to the worktree directory */
58
+ path: string;
59
+ /** Branch name (empty string if detached HEAD) */
60
+ branch: string;
61
+ /** HEAD commit hash */
62
+ head: string;
63
+ /** True for the main (original) worktree */
64
+ isMain: boolean;
65
+ /** True if bare repository worktree */
66
+ isBare: boolean;
67
+ /** True if in detached HEAD state */
68
+ isDetached: boolean;
69
+ /** True if worktree is locked (prevented from auto-pruning) */
70
+ locked: boolean;
71
+ /** Reason for lock, if any */
72
+ lockReason?: string;
73
+ /** True if this worktree can be pruned (directory missing/stale) */
74
+ prunable: boolean;
75
+ }
@@ -0,0 +1,33 @@
1
+ export interface TeamInfo {
2
+ name: string;
3
+ description: string;
4
+ createdAt: number;
5
+ leadSessionId: string;
6
+ memberCount: number;
7
+ cwd?: string;
8
+ }
9
+
10
+ export interface TeamMember {
11
+ name: string;
12
+ agentId: string;
13
+ agentType: string;
14
+ model: string;
15
+ joinedAt: number;
16
+ status: "active" | "idle" | "shutdown";
17
+ }
18
+
19
+ export interface InboxMessage {
20
+ from: string;
21
+ to: string;
22
+ text: string;
23
+ timestamp: string;
24
+ read: boolean;
25
+ color?: string;
26
+ summary?: string;
27
+ parsedType?: "task_assignment" | "idle_notification" | "completion" | "shutdown_request" | "shutdown_approved" | "message";
28
+ }
29
+
30
+ export interface TeamDetail extends TeamInfo {
31
+ members: TeamMember[];
32
+ messages: InboxMessage[];
33
+ }
@@ -98,6 +98,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
98
98
  reconnect,
99
99
  refetchMessages,
100
100
  isConnected,
101
+ teamActivity,
102
+ teamMessages,
103
+ markTeamRead,
101
104
  } = useChat(sessionId, providerId, projectName);
102
105
 
103
106
  // When CLI provider assigns a different session ID, update our state
@@ -403,6 +406,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
403
406
  onModeChange={setPermissionMode}
404
407
  providerId={providerId}
405
408
  onProviderChange={!sessionId ? setProviderId : undefined}
409
+ teamActivity={teamActivity}
410
+ onTeamOpen={markTeamRead}
411
+ teamMessages={teamMessages}
406
412
  />
407
413
  </div>
408
414
 
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useCallback, useEffect, memo, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
2
- import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff, Zap, ListOrdered, Clock } from "lucide-react";
2
+ import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff, Zap, ListOrdered, Clock, Users } from "lucide-react";
3
3
  import { useVoiceInput } from "@/hooks/use-voice-input";
4
4
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
5
5
  import { randomId } from "@/lib/utils";
@@ -7,6 +7,8 @@ import { isSupportedFile, isImageFile } from "@/lib/file-support";
7
7
  import { AttachmentChips } from "./attachment-chips";
8
8
  import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
9
9
  import { ProviderSelector } from "./provider-selector";
10
+ import { TeamActivityPopover } from "./team-activity-popover";
11
+ import type { TeamMessageItem } from "@/hooks/use-chat";
10
12
  import type { SlashItem } from "./slash-command-picker";
11
13
  import type { FileNode } from "../../../types/project";
12
14
  import { flattenFileTree } from "./file-picker";
@@ -52,6 +54,17 @@ interface MessageInputProps {
52
54
  providerId?: string;
53
55
  /** Provider change handler — undefined when session is active (locked) */
54
56
  onProviderChange?: (providerId: string) => void;
57
+ /** Team activity state from use-chat */
58
+ teamActivity?: {
59
+ hasTeams: boolean;
60
+ teamNames: string[];
61
+ messageCount: number;
62
+ unreadCount: number;
63
+ };
64
+ /** Called when user opens team popover (marks messages as read) */
65
+ onTeamOpen?: () => void;
66
+ /** Team messages for popover */
67
+ teamMessages?: TeamMessageItem[];
55
68
  }
56
69
 
57
70
  export const MessageInput = memo(function MessageInput({
@@ -73,10 +86,14 @@ export const MessageInput = memo(function MessageInput({
73
86
  onModeChange,
74
87
  providerId,
75
88
  onProviderChange,
89
+ teamActivity,
90
+ onTeamOpen,
91
+ teamMessages,
76
92
  }: MessageInputProps) {
77
93
  const [value, setValue] = useState(initialValue ?? "");
78
94
  const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
79
95
  const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
96
+ const [teamPopoverOpen, setTeamPopoverOpen] = useState(false);
80
97
  const [pendingSend, setPendingSend] = useState(false);
81
98
  const [priority, setPriority] = useState<MessagePriority>('next');
82
99
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -520,6 +537,32 @@ export const MessageInput = memo(function MessageInput({
520
537
  />
521
538
  )}
522
539
  {isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
540
+ {teamActivity?.hasTeams && (
541
+ <div className="relative">
542
+ <button
543
+ type="button"
544
+ onClick={(e) => {
545
+ e.stopPropagation();
546
+ const next = !teamPopoverOpen;
547
+ setTeamPopoverOpen(next);
548
+ if (next) onTeamOpen?.();
549
+ }}
550
+ className="relative flex items-center justify-center size-7 rounded-full text-text-subtle hover:text-text-primary transition-colors"
551
+ aria-label="Team activity"
552
+ >
553
+ <Users className="size-3.5" />
554
+ {(teamActivity.unreadCount ?? 0) > 0 && (
555
+ <span className="absolute -top-0.5 -right-0.5 size-2 bg-primary rounded-full animate-pulse" />
556
+ )}
557
+ </button>
558
+ <TeamActivityPopover
559
+ teamNames={teamActivity.teamNames}
560
+ messages={teamMessages ?? []}
561
+ open={teamPopoverOpen}
562
+ onOpenChange={setTeamPopoverOpen}
563
+ />
564
+ </div>
565
+ )}
523
566
  </div>
524
567
  {/* Mobile: single row — attach + textarea + mic + send */}
525
568
  <div className="flex items-end gap-1 md:hidden px-2 py-2">
@@ -628,6 +671,32 @@ export const MessageInput = memo(function MessageInput({
628
671
  />
629
672
  )}
630
673
  {isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
674
+ {teamActivity?.hasTeams && (
675
+ <div className="relative">
676
+ <button
677
+ type="button"
678
+ onClick={(e) => {
679
+ e.stopPropagation();
680
+ const next = !teamPopoverOpen;
681
+ setTeamPopoverOpen(next);
682
+ if (next) onTeamOpen?.();
683
+ }}
684
+ className="relative flex items-center justify-center size-8 rounded-full text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors"
685
+ aria-label="Team activity"
686
+ >
687
+ <Users className="size-4" />
688
+ {(teamActivity.unreadCount ?? 0) > 0 && (
689
+ <span className="absolute -top-0.5 -right-0.5 size-2.5 bg-primary rounded-full animate-pulse" />
690
+ )}
691
+ </button>
692
+ <TeamActivityPopover
693
+ teamNames={teamActivity.teamNames}
694
+ messages={teamMessages ?? []}
695
+ open={teamPopoverOpen}
696
+ onOpenChange={setTeamPopoverOpen}
697
+ />
698
+ </div>
699
+ )}
631
700
  </div>
632
701
  <div className="flex items-center gap-1">
633
702
  {voice.supported && (