@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.
- package/CHANGELOG.md +15 -0
- package/dist/web/assets/{browser-tab-CpltAQ9R.js → browser-tab-CWkYQN8G.js} +1 -1
- package/dist/web/assets/chat-tab-BVf4q-TX.js +8 -0
- package/dist/web/assets/code-editor-r5T7wq0I.js +2 -0
- package/dist/web/assets/{database-viewer-DUDwfC9o.js → database-viewer-DIKCOXIJ.js} +1 -1
- package/dist/web/assets/{diff-viewer-C8wC1442.js → diff-viewer-Cs2knua9.js} +1 -1
- package/dist/web/assets/{extension-webview-jl1C2HGm.js → extension-webview-BbDesnaR.js} +1 -1
- package/dist/web/assets/{git-graph-BgpRKeIW.js → git-graph-BWQm8I8V.js} +1 -1
- package/dist/web/assets/index-D-NC2Rnz.css +2 -0
- package/dist/web/assets/index-E0N-qark.js +37 -0
- package/dist/web/assets/keybindings-store-DXucgQSx.js +1 -0
- package/dist/web/assets/{markdown-renderer-fLOZi3vK.js → markdown-renderer-DgeFkhU6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DhEmO5Pd.js → postgres-viewer-hQ47YDO5.js} +1 -1
- package/dist/web/assets/{settings-tab-B0JPmP_y.js → settings-tab-BZ_CLZDj.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4YwpO6X6.js → sqlite-viewer-B8vs7svK.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dc1b9QaT.js → terminal-tab-DjE8lCXj.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CN0etsaO.js → use-monaco-theme-BNtfLGmz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +8 -0
- package/docs/project-roadmap.md +2 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/server/index.ts +4 -0
- package/src/server/routes/git.ts +56 -0
- package/src/server/routes/teams.ts +40 -0
- package/src/server/ws/chat.ts +42 -0
- package/src/server/ws/team-inbox-watcher.ts +181 -0
- package/src/services/git.service.ts +99 -0
- package/src/types/chat.ts +4 -1
- package/src/types/git.ts +21 -0
- package/src/types/team.ts +33 -0
- package/src/web/components/chat/chat-tab.tsx +6 -0
- package/src/web/components/chat/message-input.tsx +70 -1
- package/src/web/components/chat/team-activity-popover.tsx +202 -0
- package/src/web/components/git/create-worktree-dialog.tsx +232 -0
- package/src/web/components/git/git-status-panel.tsx +13 -0
- package/src/web/components/git/git-worktree-panel.tsx +306 -0
- package/src/web/components/layout/draggable-tab.tsx +7 -1
- package/src/web/components/layout/editor-panel.tsx +1 -1
- package/src/web/components/layout/split-drop-overlay.tsx +10 -5
- package/src/web/components/layout/tab-bar.tsx +6 -0
- package/src/web/components/settings/ai-settings-section.tsx +83 -1
- package/src/web/hooks/use-chat.ts +96 -1
- package/src/web/hooks/use-media-query.ts +18 -0
- package/src/web/hooks/use-tab-drag.ts +1 -1
- package/src/web/hooks/use-touch-tab-drag.ts +190 -0
- package/dist/web/assets/chat-tab-BDvg70wQ.js +0 -8
- package/dist/web/assets/code-editor-CQXdf1v_.js +0 -2
- package/dist/web/assets/index-BPTNjFbl.js +0 -37
- package/dist/web/assets/index-D3XyoeN3.css +0 -2
- 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
|
+
});
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -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 && (
|