@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -3
  2. package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
  3. package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
  4. package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
  5. package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
  6. package/dist/web/assets/index-C_yeSRZ0.css +2 -0
  7. package/dist/web/assets/index-CgNJBFj4.js +21 -0
  8. package/dist/web/assets/input-AESbQWjx.js +41 -0
  9. package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
  10. package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
  11. package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
  12. package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
  13. package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
  15. package/dist/web/index.html +7 -5
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/providers/claude-agent-sdk.ts +83 -10
  19. package/src/server/index.ts +81 -1
  20. package/src/server/ws/chat.ts +10 -0
  21. package/src/types/api.ts +3 -3
  22. package/src/types/chat.ts +3 -3
  23. package/src/web/app.tsx +11 -3
  24. package/src/web/components/chat/chat-history-bar.tsx +231 -0
  25. package/src/web/components/chat/chat-tab.tsx +19 -66
  26. package/src/web/components/chat/message-list.tsx +4 -114
  27. package/src/web/components/chat/tool-cards.tsx +54 -14
  28. package/src/web/components/editor/code-editor.tsx +26 -39
  29. package/src/web/components/editor/diff-viewer.tsx +0 -21
  30. package/src/web/components/layout/command-palette.tsx +145 -15
  31. package/src/web/components/layout/draggable-tab.tsx +2 -0
  32. package/src/web/components/layout/editor-panel.tsx +44 -5
  33. package/src/web/components/layout/sidebar.tsx +53 -7
  34. package/src/web/components/layout/tab-bar.tsx +30 -48
  35. package/src/web/components/settings/ai-settings-section.tsx +28 -19
  36. package/src/web/components/settings/settings-tab.tsx +24 -21
  37. package/src/web/components/shared/markdown-renderer.tsx +223 -0
  38. package/src/web/components/ui/scroll-area.tsx +2 -2
  39. package/src/web/hooks/use-chat.ts +78 -83
  40. package/src/web/hooks/use-global-keybindings.ts +30 -2
  41. package/src/web/stores/panel-store.ts +2 -9
  42. package/src/web/stores/settings-store.ts +12 -2
  43. package/src/web/styles/globals.css +14 -4
  44. package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
  45. package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
  46. package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
  47. package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
  48. package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
  49. package/dist/web/assets/index-3zt5mBwZ.css +0 -2
  50. package/dist/web/assets/index-CaUQy3Zs.js +0 -21
  51. package/dist/web/assets/input-CTnwfHVN.js +0 -41
  52. package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
  53. package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
  54. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
  55. /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
  56. /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
  57. /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
  58. /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 executing internally by SDK) */
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
- // When tools were pending and a new assistant/stream_event arrives,
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
- if (pendingToolCount > 0 && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
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
- pendingToolCount++;
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 "";
@@ -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);
@@ -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
+ }