@hienlh/ppm 0.2.21 → 0.4.0
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 +53 -3
- package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
- package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
- package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
- package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
- package/dist/web/assets/index-C_yeSRZ0.css +2 -0
- package/dist/web/assets/index-CgNJBFj4.js +21 -0
- package/dist/web/assets/input-AESbQWjx.js +41 -0
- package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
- package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
- package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
- package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
- package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
- package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
- package/dist/web/index.html +7 -5
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +83 -10
- package/src/server/index.ts +81 -1
- package/src/server/ws/chat.ts +10 -0
- package/src/types/api.ts +3 -3
- package/src/types/chat.ts +3 -3
- package/src/web/app.tsx +11 -3
- package/src/web/components/chat/chat-history-bar.tsx +231 -0
- package/src/web/components/chat/chat-tab.tsx +19 -66
- package/src/web/components/chat/message-list.tsx +4 -114
- package/src/web/components/chat/tool-cards.tsx +54 -14
- package/src/web/components/editor/code-editor.tsx +26 -39
- package/src/web/components/editor/diff-viewer.tsx +0 -21
- package/src/web/components/layout/command-palette.tsx +145 -15
- package/src/web/components/layout/draggable-tab.tsx +2 -0
- package/src/web/components/layout/editor-panel.tsx +44 -5
- package/src/web/components/layout/sidebar.tsx +53 -7
- package/src/web/components/layout/tab-bar.tsx +30 -48
- package/src/web/components/settings/ai-settings-section.tsx +28 -19
- package/src/web/components/settings/settings-tab.tsx +24 -21
- package/src/web/components/shared/markdown-renderer.tsx +223 -0
- package/src/web/components/ui/scroll-area.tsx +2 -2
- package/src/web/hooks/use-chat.ts +78 -83
- package/src/web/hooks/use-global-keybindings.ts +30 -2
- package/src/web/stores/panel-store.ts +2 -9
- package/src/web/stores/settings-store.ts +12 -2
- package/src/web/styles/globals.css +14 -4
- package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
- package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
- package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
- package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
- package/dist/web/assets/index-3zt5mBwZ.css +0 -2
- package/dist/web/assets/index-CaUQy3Zs.js +0 -21
- package/dist/web/assets/input-CTnwfHVN.js +0 -41
- package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
- package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
- /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
- /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
- /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
- /package/dist/web/assets/{utils-B-_GCz7E.js → utils-bntUtdc7.js} +0 -0
|
@@ -300,10 +300,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
300
300
|
this.activeQueries.set(sessionId, q);
|
|
301
301
|
|
|
302
302
|
let lastPartialText = "";
|
|
303
|
-
/** Number of tool_use blocks pending results (tools
|
|
303
|
+
/** Number of tool_use blocks pending results (top-level tools only, not subagent children) */
|
|
304
304
|
let pendingToolCount = 0;
|
|
305
305
|
|
|
306
306
|
for await (const msg of q) {
|
|
307
|
+
// Extract parent_tool_use_id from SDK message (present on subagent-scoped messages)
|
|
308
|
+
const parentId = (msg as any).parent_tool_use_id as string | undefined;
|
|
307
309
|
|
|
308
310
|
// Yield any queued approval events
|
|
309
311
|
while (approvalEvents.length > 0) {
|
|
@@ -326,9 +328,31 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
326
328
|
continue;
|
|
327
329
|
}
|
|
328
330
|
|
|
329
|
-
//
|
|
331
|
+
// Handle `user` messages directly — they contain tool_result blocks (e.g. after Agent finishes).
|
|
332
|
+
// Extract tool_results from user messages that are top-level (no parentId).
|
|
333
|
+
if ((msg as any).type === "user" && !parentId) {
|
|
334
|
+
const userContent = (msg as any).message?.content;
|
|
335
|
+
if (Array.isArray(userContent)) {
|
|
336
|
+
for (const block of userContent) {
|
|
337
|
+
if (block.type === "tool_result") {
|
|
338
|
+
const output = block.content ?? block.output ?? "";
|
|
339
|
+
yield {
|
|
340
|
+
type: "tool_result" as const,
|
|
341
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
342
|
+
isError: !!block.is_error,
|
|
343
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
344
|
+
};
|
|
345
|
+
if (pendingToolCount > 0) pendingToolCount--;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// When top-level tools were pending and a new TOP-LEVEL message arrives,
|
|
330
353
|
// the SDK has finished executing tools. Fetch tool_results from session history.
|
|
331
|
-
|
|
354
|
+
// Skip this for child messages (parentId set) — subagent internals don't mean parent tools finished.
|
|
355
|
+
if (pendingToolCount > 0 && !parentId && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
|
|
332
356
|
try {
|
|
333
357
|
const sessionMsgs = await getSessionMessages(sdkId);
|
|
334
358
|
// Find the last user message — it contains tool_result blocks
|
|
@@ -365,7 +389,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
365
389
|
const text = event.delta.text ?? "";
|
|
366
390
|
if (text) {
|
|
367
391
|
lastPartialText += text;
|
|
368
|
-
yield { type: "text", content: text };
|
|
392
|
+
yield { type: "text", content: text, ...(parentId && { parentToolUseId: parentId }) };
|
|
369
393
|
}
|
|
370
394
|
}
|
|
371
395
|
continue;
|
|
@@ -380,7 +404,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
380
404
|
if (fullText.length > lastPartialText.length) {
|
|
381
405
|
const delta = fullText.slice(lastPartialText.length);
|
|
382
406
|
lastPartialText = fullText;
|
|
383
|
-
yield { type: "text", content: delta };
|
|
407
|
+
yield { type: "text", content: delta, ...(parentId && { parentToolUseId: parentId }) };
|
|
384
408
|
}
|
|
385
409
|
}
|
|
386
410
|
continue;
|
|
@@ -393,19 +417,25 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
393
417
|
for (const block of content) {
|
|
394
418
|
if (block.type === "text" && typeof block.text === "string") {
|
|
395
419
|
if (block.text.length > lastPartialText.length) {
|
|
396
|
-
yield { type: "text", content: block.text.slice(lastPartialText.length) };
|
|
420
|
+
yield { type: "text", content: block.text.slice(lastPartialText.length), ...(parentId && { parentToolUseId: parentId }) };
|
|
397
421
|
} else if (lastPartialText.length === 0) {
|
|
398
|
-
yield { type: "text", content: block.text };
|
|
422
|
+
yield { type: "text", content: block.text, ...(parentId && { parentToolUseId: parentId }) };
|
|
399
423
|
}
|
|
400
424
|
assistantContent += block.text;
|
|
401
425
|
lastPartialText = "";
|
|
402
426
|
} else if (block.type === "tool_use") {
|
|
403
|
-
|
|
427
|
+
// Only track pending count for top-level tools (not subagent children).
|
|
428
|
+
// Child tools are executed internally by the SDK subagent — their results
|
|
429
|
+
// stream as child messages and don't need the pendingToolCount flush mechanism.
|
|
430
|
+
if (!parentId) {
|
|
431
|
+
pendingToolCount++;
|
|
432
|
+
}
|
|
404
433
|
yield {
|
|
405
434
|
type: "tool_use",
|
|
406
435
|
tool: block.name ?? "unknown",
|
|
407
436
|
input: block.input ?? {},
|
|
408
437
|
toolUseId: block.id as string | undefined,
|
|
438
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
409
439
|
};
|
|
410
440
|
}
|
|
411
441
|
}
|
|
@@ -549,6 +579,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
549
579
|
merged.push(msg);
|
|
550
580
|
}
|
|
551
581
|
|
|
582
|
+
// Nest child events under their parent Agent/Task tool_use's children array
|
|
583
|
+
for (const msg of merged) {
|
|
584
|
+
if (!msg.events) continue;
|
|
585
|
+
nestChildEvents(msg.events);
|
|
586
|
+
}
|
|
587
|
+
|
|
552
588
|
return merged.filter(
|
|
553
589
|
(msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
|
|
554
590
|
);
|
|
@@ -559,9 +595,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
559
595
|
}
|
|
560
596
|
|
|
561
597
|
/** Parse SDK SessionMessage into ChatMessage with events for tool_use blocks */
|
|
562
|
-
function parseSessionMessage(msg: { uuid: string; type: string; message: unknown }): ChatMessage {
|
|
598
|
+
function parseSessionMessage(msg: { uuid: string; type: string; message: unknown; parent_tool_use_id?: string | null }): ChatMessage {
|
|
563
599
|
const message = msg.message as Record<string, unknown> | undefined;
|
|
564
600
|
const role = msg.type as "user" | "assistant";
|
|
601
|
+
const parentId = (msg as any).parent_tool_use_id as string | undefined;
|
|
565
602
|
|
|
566
603
|
// Parse content blocks for both user and assistant messages
|
|
567
604
|
const events: ChatEvent[] = [];
|
|
@@ -572,7 +609,7 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
|
|
|
572
609
|
if (block.type === "text" && typeof block.text === "string") {
|
|
573
610
|
textContent += block.text;
|
|
574
611
|
if (role === "assistant") {
|
|
575
|
-
events.push({ type: "text", content: block.text });
|
|
612
|
+
events.push({ type: "text", content: block.text, ...(parentId && { parentToolUseId: parentId }) });
|
|
576
613
|
}
|
|
577
614
|
} else if (block.type === "tool_use") {
|
|
578
615
|
events.push({
|
|
@@ -580,6 +617,7 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
|
|
|
580
617
|
tool: (block.name as string) ?? "unknown",
|
|
581
618
|
input: block.input ?? {},
|
|
582
619
|
toolUseId: block.id as string | undefined,
|
|
620
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
583
621
|
});
|
|
584
622
|
} else if (block.type === "tool_result") {
|
|
585
623
|
const output = block.content ?? block.output ?? "";
|
|
@@ -588,6 +626,7 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
|
|
|
588
626
|
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
589
627
|
isError: !!(block as Record<string, unknown>).is_error,
|
|
590
628
|
toolUseId: block.tool_use_id as string | undefined,
|
|
629
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
591
630
|
});
|
|
592
631
|
}
|
|
593
632
|
}
|
|
@@ -604,6 +643,40 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
|
|
|
604
643
|
};
|
|
605
644
|
}
|
|
606
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Move events with parentToolUseId into their parent Agent/Task tool_use's children array.
|
|
648
|
+
* Mutates the array in-place: child events are removed from the top level and pushed into parent.children.
|
|
649
|
+
*/
|
|
650
|
+
function nestChildEvents(events: ChatEvent[]): void {
|
|
651
|
+
// Build map of Agent/Task tool_use events by toolUseId
|
|
652
|
+
const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
|
|
653
|
+
for (const ev of events) {
|
|
654
|
+
if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
|
|
655
|
+
parentMap.set(ev.toolUseId, ev);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (parentMap.size === 0) return;
|
|
659
|
+
|
|
660
|
+
// Collect indices of child events to remove
|
|
661
|
+
const childIndices: number[] = [];
|
|
662
|
+
for (let i = 0; i < events.length; i++) {
|
|
663
|
+
const ev = events[i]!;
|
|
664
|
+
const pid = (ev as any).parentToolUseId as string | undefined;
|
|
665
|
+
if (!pid) continue;
|
|
666
|
+
const parent = parentMap.get(pid);
|
|
667
|
+
if (parent) {
|
|
668
|
+
if (!parent.children) parent.children = [];
|
|
669
|
+
parent.children.push(ev);
|
|
670
|
+
childIndices.push(i);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Remove children from flat array (reverse order to keep indices valid)
|
|
675
|
+
for (let i = childIndices.length - 1; i >= 0; i--) {
|
|
676
|
+
events.splice(childIndices[i]!, 1);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
607
680
|
/** Extract plain text from message payload */
|
|
608
681
|
function extractText(message: unknown): string {
|
|
609
682
|
if (!message || typeof message !== "object") return "";
|
package/src/server/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { staticRoutes } from "./routes/static.ts";
|
|
|
10
10
|
import { projectScopedRouter } from "./routes/project-scoped.ts";
|
|
11
11
|
import { terminalWebSocket } from "./ws/terminal.ts";
|
|
12
12
|
import { chatWebSocket } from "./ws/chat.ts";
|
|
13
|
-
import { ok } from "../types/api.ts";
|
|
13
|
+
import { ok, err } from "../types/api.ts";
|
|
14
14
|
|
|
15
15
|
/** Tee console.log/error to ~/.ppm/ppm.log while preserving terminal output */
|
|
16
16
|
async function setupLogFile() {
|
|
@@ -96,6 +96,86 @@ if (process.env.NODE_ENV !== "production") {
|
|
|
96
96
|
app.use("/api/*", authMiddleware);
|
|
97
97
|
app.get("/api/auth/check", (c) => c.json(ok(true)));
|
|
98
98
|
|
|
99
|
+
// Filesystem file listing (for command palette) — cross-platform
|
|
100
|
+
app.get("/api/fs/list", async (c) => {
|
|
101
|
+
const dir = c.req.query("dir");
|
|
102
|
+
if (!dir) return c.json(err("dir is required"), 400);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const path = await import("node:path");
|
|
106
|
+
const fs = await import("node:fs");
|
|
107
|
+
const { homedir } = await import("node:os");
|
|
108
|
+
const resolved = dir.startsWith("~") ? path.resolve(homedir(), dir.slice(2)) : path.resolve(dir);
|
|
109
|
+
|
|
110
|
+
const SKIP = new Set([".git", "node_modules", ".DS_Store"]);
|
|
111
|
+
const MAX_FILES = 200;
|
|
112
|
+
const MAX_DEPTH = 4;
|
|
113
|
+
const files: string[] = [];
|
|
114
|
+
|
|
115
|
+
function walk(dirPath: string, depth: number) {
|
|
116
|
+
if (depth > MAX_DEPTH || files.length >= MAX_FILES) return;
|
|
117
|
+
let entries: import("node:fs").Dirent[];
|
|
118
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (SKIP.has(entry.name)) continue;
|
|
121
|
+
const full = path.join(dirPath, entry.name);
|
|
122
|
+
if (entry.isFile()) {
|
|
123
|
+
files.push(full);
|
|
124
|
+
if (files.length >= MAX_FILES) return;
|
|
125
|
+
} else if (entry.isDirectory()) {
|
|
126
|
+
walk(full, depth + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
walk(resolved, 0);
|
|
132
|
+
return c.json(ok(files));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return c.json(err((e as Error).message), 500);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Filesystem file read (for opening files outside project) — cross-platform
|
|
139
|
+
app.get("/api/fs/read", async (c) => {
|
|
140
|
+
const filePath = c.req.query("path");
|
|
141
|
+
if (!filePath) return c.json(err("path is required"), 400);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const path = await import("node:path");
|
|
145
|
+
const fs = await import("node:fs");
|
|
146
|
+
const { homedir } = await import("node:os");
|
|
147
|
+
const resolved = filePath.startsWith("~") ? path.resolve(homedir(), filePath.slice(2)) : path.resolve(filePath);
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(resolved)) return c.json(err("File not found"), 404);
|
|
150
|
+
const stat = fs.statSync(resolved);
|
|
151
|
+
if (!stat.isFile()) return c.json(err("Not a file"), 400);
|
|
152
|
+
if (stat.size > 5 * 1024 * 1024) return c.json(err("File too large (>5MB)"), 400);
|
|
153
|
+
|
|
154
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
155
|
+
return c.json(ok({ content, path: resolved }));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return c.json(err((e as Error).message), 500);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Filesystem file write (for saving files outside project) — cross-platform
|
|
162
|
+
app.put("/api/fs/write", async (c) => {
|
|
163
|
+
const body = await c.req.json<{ path: string; content: string }>();
|
|
164
|
+
if (!body.path || body.content == null) return c.json(err("path and content required"), 400);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const pathMod = await import("node:path");
|
|
168
|
+
const fs = await import("node:fs");
|
|
169
|
+
const { homedir } = await import("node:os");
|
|
170
|
+
const resolved = body.path.startsWith("~") ? pathMod.resolve(homedir(), body.path.slice(2)) : pathMod.resolve(body.path);
|
|
171
|
+
|
|
172
|
+
fs.writeFileSync(resolved, body.content, "utf-8");
|
|
173
|
+
return c.json(ok(true));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return c.json(err((e as Error).message), 500);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
99
179
|
// API routes
|
|
100
180
|
app.route("/api/settings", settingsRoutes);
|
|
101
181
|
app.route("/api/push", pushRoutes);
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -250,6 +250,16 @@ export const chatWebSocket = {
|
|
|
250
250
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// If already streaming, abort current query first and wait for cleanup
|
|
254
|
+
if (entry?.isStreaming && entry.abort) {
|
|
255
|
+
console.log(`[chat] session=${sessionId} aborting current query for new message`);
|
|
256
|
+
entry.abort.abort();
|
|
257
|
+
// Wait for stream loop to finish cleanup
|
|
258
|
+
if (entry.streamPromise) {
|
|
259
|
+
await entry.streamPromise;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
253
263
|
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
254
264
|
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
255
265
|
if (entry) {
|
package/src/types/api.ts
CHANGED
|
@@ -28,9 +28,9 @@ export type ChatWsClientMessage =
|
|
|
28
28
|
| { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown };
|
|
29
29
|
|
|
30
30
|
export type ChatWsServerMessage =
|
|
31
|
-
| { type: "text"; content: string }
|
|
32
|
-
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string }
|
|
33
|
-
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string }
|
|
31
|
+
| { type: "text"; content: string; parentToolUseId?: string }
|
|
32
|
+
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string }
|
|
33
|
+
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
|
|
34
34
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
35
35
|
| { type: "usage"; usage: { totalCostUsd?: number; fiveHour?: number; sevenDay?: number } }
|
|
36
36
|
| { type: "done"; sessionId: string }
|
package/src/types/chat.ts
CHANGED
|
@@ -75,9 +75,9 @@ export type ResultSubtype =
|
|
|
75
75
|
| "error_during_execution";
|
|
76
76
|
|
|
77
77
|
export type ChatEvent =
|
|
78
|
-
| { type: "text"; content: string }
|
|
79
|
-
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string }
|
|
80
|
-
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string }
|
|
78
|
+
| { type: "text"; content: string; parentToolUseId?: string }
|
|
79
|
+
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string; children?: ChatEvent[] }
|
|
80
|
+
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
|
|
81
81
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
82
82
|
| { type: "usage"; usage: UsageInfo }
|
|
83
83
|
| { type: "error"; message: string }
|
package/src/web/app.tsx
CHANGED
|
@@ -31,6 +31,7 @@ export function App() {
|
|
|
31
31
|
() => new Set(["__global__"]),
|
|
32
32
|
);
|
|
33
33
|
const theme = useSettingsStore((s) => s.theme);
|
|
34
|
+
const deviceName = useSettingsStore((s) => s.deviceName);
|
|
34
35
|
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
|
35
36
|
const fetchServerInfo = useSettingsStore((s) => s.fetchServerInfo);
|
|
36
37
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
@@ -82,7 +83,7 @@ export function App() {
|
|
|
82
83
|
useUrlSync();
|
|
83
84
|
|
|
84
85
|
// Global keyboard shortcuts (Shift+Shift → command palette, Alt+[/] → cycle tabs)
|
|
85
|
-
const { paletteOpen, closePalette } = useGlobalKeybindings();
|
|
86
|
+
const { paletteOpen, paletteInitialQuery, closePalette } = useGlobalKeybindings();
|
|
86
87
|
|
|
87
88
|
// Health check — detects server crash/restart
|
|
88
89
|
useHealthCheck();
|
|
@@ -158,7 +159,14 @@ export function App() {
|
|
|
158
159
|
|
|
159
160
|
return (
|
|
160
161
|
<TooltipProvider>
|
|
161
|
-
<div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden">
|
|
162
|
+
<div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
|
|
163
|
+
{/* Mobile device name badge — floating top-left */}
|
|
164
|
+
{deviceName && (
|
|
165
|
+
<div className="md:hidden fixed top-0 left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br">
|
|
166
|
+
{deviceName}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
162
170
|
{/* Main layout */}
|
|
163
171
|
<div className="flex flex-1 overflow-hidden">
|
|
164
172
|
{/* Desktop project bar (far left, non-collapsible) */}
|
|
@@ -200,7 +208,7 @@ export function App() {
|
|
|
200
208
|
/>
|
|
201
209
|
|
|
202
210
|
{/* Command palette (Shift+Shift) */}
|
|
203
|
-
<CommandPalette open={paletteOpen} onClose={closePalette} />
|
|
211
|
+
<CommandPalette open={paletteOpen} onClose={closePalette} initialQuery={paletteInitialQuery} />
|
|
204
212
|
|
|
205
213
|
{/* Toast notifications */}
|
|
206
214
|
<Toaster
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
|
|
3
|
+
import { Activity } from "lucide-react";
|
|
4
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
5
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
6
|
+
import { AISettingsSection } from "@/components/settings/ai-settings-section";
|
|
7
|
+
import { UsageDetailPanel } from "./usage-badge";
|
|
8
|
+
import type { SessionInfo } from "../../../types/chat";
|
|
9
|
+
import type { UsageInfo } from "../../../types/chat";
|
|
10
|
+
|
|
11
|
+
type PanelType = "history" | "config" | "usage" | null;
|
|
12
|
+
|
|
13
|
+
interface ChatHistoryBarProps {
|
|
14
|
+
projectName: string;
|
|
15
|
+
usageInfo: UsageInfo;
|
|
16
|
+
usageLoading?: boolean;
|
|
17
|
+
refreshUsage?: () => void;
|
|
18
|
+
lastUpdatedAt?: number | null;
|
|
19
|
+
sessionId?: string | null;
|
|
20
|
+
onBugReport?: () => void;
|
|
21
|
+
isConnected?: boolean;
|
|
22
|
+
onReconnect?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatDate(iso: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pctColor(pct: number): string {
|
|
34
|
+
if (pct >= 90) return "text-red-500";
|
|
35
|
+
if (pct >= 70) return "text-amber-500";
|
|
36
|
+
return "text-green-500";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ChatHistoryBar({
|
|
40
|
+
projectName, usageInfo, usageLoading, refreshUsage, lastUpdatedAt,
|
|
41
|
+
sessionId, onBugReport, isConnected, onReconnect,
|
|
42
|
+
}: ChatHistoryBarProps) {
|
|
43
|
+
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
44
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
45
|
+
const [loading, setLoading] = useState(false);
|
|
46
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
47
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
48
|
+
|
|
49
|
+
const togglePanel = (panel: PanelType) => {
|
|
50
|
+
setActivePanel((prev) => prev === panel ? null : panel);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const load = useCallback(async () => {
|
|
54
|
+
if (!projectName) return;
|
|
55
|
+
setLoading(true);
|
|
56
|
+
try {
|
|
57
|
+
const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
|
|
58
|
+
setSessions(data);
|
|
59
|
+
} catch {
|
|
60
|
+
// silent
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}
|
|
64
|
+
}, [projectName]);
|
|
65
|
+
|
|
66
|
+
// Load sessions when history panel opens
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (activePanel === "history" && sessions.length === 0) load();
|
|
69
|
+
}, [activePanel]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
70
|
+
|
|
71
|
+
function openSession(session: SessionInfo) {
|
|
72
|
+
openTab({
|
|
73
|
+
type: "chat",
|
|
74
|
+
title: session.title || "Chat",
|
|
75
|
+
projectId: projectName ?? null,
|
|
76
|
+
metadata: { projectName, sessionId: session.id },
|
|
77
|
+
closable: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Filter sessions by search query
|
|
82
|
+
const filteredSessions = searchQuery.trim()
|
|
83
|
+
? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
|
|
84
|
+
: sessions;
|
|
85
|
+
|
|
86
|
+
// Usage badge display
|
|
87
|
+
const fiveHourPct = usageInfo.fiveHour != null ? Math.round(usageInfo.fiveHour * 100) : null;
|
|
88
|
+
const sevenDayPct = usageInfo.sevenDay != null ? Math.round(usageInfo.sevenDay * 100) : null;
|
|
89
|
+
const worstPct = Math.max(fiveHourPct ?? 0, sevenDayPct ?? 0);
|
|
90
|
+
const usageColor = fiveHourPct != null || sevenDayPct != null ? pctColor(worstPct) : "text-text-subtle";
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="border-b border-border/50">
|
|
94
|
+
{/* Toolbar row — all buttons on one line */}
|
|
95
|
+
<div className="flex items-center gap-1 px-2 py-1">
|
|
96
|
+
{/* History */}
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => togglePanel("history")}
|
|
99
|
+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
|
|
100
|
+
activePanel === "history" ? "text-primary bg-primary/10" : "text-text-secondary hover:text-foreground hover:bg-surface-elevated"
|
|
101
|
+
}`}
|
|
102
|
+
>
|
|
103
|
+
<History className="size-3" />
|
|
104
|
+
<span>History</span>
|
|
105
|
+
</button>
|
|
106
|
+
|
|
107
|
+
{/* Config */}
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => togglePanel("config")}
|
|
110
|
+
className={`p-1 rounded transition-colors ${
|
|
111
|
+
activePanel === "config" ? "text-primary bg-primary/10" : "text-text-subtle hover:text-text-secondary hover:bg-surface-elevated"
|
|
112
|
+
}`}
|
|
113
|
+
title="AI Settings"
|
|
114
|
+
>
|
|
115
|
+
<Settings2 className="size-3" />
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{/* Usage badge */}
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => togglePanel("usage")}
|
|
121
|
+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium tabular-nums transition-colors hover:bg-surface-elevated ${
|
|
122
|
+
activePanel === "usage" ? "bg-primary/10" : ""
|
|
123
|
+
} ${usageColor}`}
|
|
124
|
+
title="Usage limits"
|
|
125
|
+
>
|
|
126
|
+
<Activity className="size-3" />
|
|
127
|
+
<span>5h:{fiveHourPct != null ? `${fiveHourPct}%` : "--%"}</span>
|
|
128
|
+
<span className="text-text-subtle">·</span>
|
|
129
|
+
<span>Wk:{sevenDayPct != null ? `${sevenDayPct}%` : "--%"}</span>
|
|
130
|
+
</button>
|
|
131
|
+
|
|
132
|
+
{/* Spacer */}
|
|
133
|
+
<div className="flex-1" />
|
|
134
|
+
|
|
135
|
+
{/* Bug report */}
|
|
136
|
+
{sessionId && onBugReport && (
|
|
137
|
+
<button
|
|
138
|
+
onClick={onBugReport}
|
|
139
|
+
className="p-1 rounded hover:bg-surface-elevated text-text-subtle hover:text-text-secondary transition-colors"
|
|
140
|
+
title="Report bug"
|
|
141
|
+
>
|
|
142
|
+
<MessageSquare className="size-3" />
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Connection indicator */}
|
|
147
|
+
{onReconnect && (
|
|
148
|
+
<button
|
|
149
|
+
onClick={onReconnect}
|
|
150
|
+
className="size-4 flex items-center justify-center"
|
|
151
|
+
title={isConnected ? "Connected" : "Disconnected — click to reconnect"}
|
|
152
|
+
>
|
|
153
|
+
<span className={`size-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"}`} />
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Panels — only one visible at a time */}
|
|
159
|
+
|
|
160
|
+
{/* History panel */}
|
|
161
|
+
{activePanel === "history" && (
|
|
162
|
+
<div className="border-t border-border/30 bg-surface">
|
|
163
|
+
{/* Search + refresh */}
|
|
164
|
+
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/30">
|
|
165
|
+
<Search className="size-3 text-text-subtle shrink-0" />
|
|
166
|
+
<input
|
|
167
|
+
type="text"
|
|
168
|
+
value={searchQuery}
|
|
169
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
170
|
+
placeholder="Search sessions..."
|
|
171
|
+
className="flex-1 bg-transparent text-[11px] text-text-primary outline-none placeholder:text-text-subtle"
|
|
172
|
+
/>
|
|
173
|
+
<button
|
|
174
|
+
onClick={load}
|
|
175
|
+
disabled={loading}
|
|
176
|
+
className="p-0.5 rounded text-text-subtle hover:text-text-secondary transition-colors disabled:opacity-50"
|
|
177
|
+
title="Refresh"
|
|
178
|
+
>
|
|
179
|
+
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="max-h-[200px] overflow-y-auto">
|
|
184
|
+
{loading && sessions.length === 0 ? (
|
|
185
|
+
<div className="flex items-center justify-center py-3">
|
|
186
|
+
<Loader2 className="size-3.5 animate-spin text-text-subtle" />
|
|
187
|
+
</div>
|
|
188
|
+
) : filteredSessions.length === 0 ? (
|
|
189
|
+
<div className="flex items-center justify-center py-3 text-[11px] text-text-subtle">
|
|
190
|
+
{searchQuery ? "No matching sessions" : "No sessions yet"}
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
filteredSessions.map((session) => (
|
|
194
|
+
<button
|
|
195
|
+
key={session.id}
|
|
196
|
+
onClick={() => openSession(session)}
|
|
197
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors"
|
|
198
|
+
>
|
|
199
|
+
<MessageSquare className="size-3 shrink-0 text-text-subtle" />
|
|
200
|
+
<span className="text-[11px] truncate flex-1">{session.title || "Untitled"}</span>
|
|
201
|
+
{session.updatedAt && (
|
|
202
|
+
<span className="text-[10px] text-text-subtle shrink-0">{formatDate(session.updatedAt)}</span>
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
))
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* Config panel */}
|
|
212
|
+
{activePanel === "config" && (
|
|
213
|
+
<div className="border-t border-border/30 bg-surface px-3 py-2 max-h-[280px] overflow-y-auto">
|
|
214
|
+
<AISettingsSection compact />
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{/* Usage panel */}
|
|
219
|
+
{activePanel === "usage" && (
|
|
220
|
+
<UsageDetailPanel
|
|
221
|
+
usage={usageInfo}
|
|
222
|
+
visible={true}
|
|
223
|
+
onClose={() => setActivePanel(null)}
|
|
224
|
+
onReload={refreshUsage}
|
|
225
|
+
loading={usageLoading}
|
|
226
|
+
lastUpdatedAt={lastUpdatedAt}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|