@hienlh/ppm 0.4.4 → 0.5.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 (62) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/bun.lock +3 -0
  3. package/dist/web/assets/api-client-ANLU-Irq.js +1 -0
  4. package/dist/web/assets/chat-tab-C6iTYbRI.js +7 -0
  5. package/dist/web/assets/code-editor-hnDDc8JZ.js +1 -0
  6. package/dist/web/assets/{diff-viewer-B9oX4DDx.js → diff-viewer-BWeMVAvK.js} +1 -1
  7. package/dist/web/assets/git-graph-D6oftHHC.js +1 -0
  8. package/dist/web/assets/index-CWwJBtaO.js +21 -0
  9. package/dist/web/assets/index-jmj5f_bQ.css +2 -0
  10. package/dist/web/assets/{input-AESbQWjx.js → input-D-F4ITU0.js} +1 -1
  11. package/dist/web/assets/jsx-runtime-B4BJKQ1u.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-DdDDhQDx.js → markdown-renderer-PHBaNQ3l.js} +2 -2
  13. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  14. package/dist/web/assets/rotate-ccw-BesidNnx.js +1 -0
  15. package/dist/web/assets/settings-store-CGtTcr8r.js +1 -0
  16. package/dist/web/assets/settings-tab-BpETyigv.js +1 -0
  17. package/dist/web/assets/tab-store-Dq1kMOkJ.js +1 -0
  18. package/dist/web/assets/{terminal-tab-BeFf07MH.js → terminal-tab-BTumEYyO.js} +2 -2
  19. package/dist/web/assets/{use-monaco-theme-Bb9W0CI2.js → use-monaco-theme-CsNwoeyj.js} +1 -1
  20. package/dist/web/index.html +8 -7
  21. package/dist/web/sw.js +1 -1
  22. package/package.json +2 -1
  23. package/src/cli/commands/stop.ts +8 -0
  24. package/src/providers/claude-agent-sdk.ts +36 -13
  25. package/src/server/index.ts +15 -0
  26. package/src/server/routes/chat.ts +31 -3
  27. package/src/server/ws/chat.ts +40 -0
  28. package/src/services/claude-usage.service.ts +51 -23
  29. package/src/services/tunnel.service.ts +4 -0
  30. package/src/types/api.ts +1 -0
  31. package/src/types/chat.ts +1 -0
  32. package/src/web/app.tsx +5 -0
  33. package/src/web/components/chat/chat-history-bar.tsx +15 -3
  34. package/src/web/components/chat/chat-tab.tsx +45 -50
  35. package/src/web/components/chat/message-input.tsx +116 -55
  36. package/src/web/components/chat/message-list.tsx +156 -69
  37. package/src/web/components/chat/usage-badge.tsx +4 -4
  38. package/src/web/components/layout/command-palette.tsx +37 -8
  39. package/src/web/components/layout/draggable-tab.tsx +4 -4
  40. package/src/web/components/layout/mobile-drawer.tsx +2 -2
  41. package/src/web/components/layout/mobile-nav.tsx +3 -2
  42. package/src/web/components/layout/project-bar.tsx +5 -3
  43. package/src/web/components/layout/project-bottom-sheet.tsx +3 -1
  44. package/src/web/components/layout/tab-bar.tsx +4 -4
  45. package/src/web/components/shared/bug-report-popup.tsx +58 -0
  46. package/src/web/hooks/use-chat.ts +63 -7
  47. package/src/web/hooks/use-usage.ts +15 -17
  48. package/src/web/lib/report-bug.ts +12 -3
  49. package/src/web/stores/project-store.ts +7 -1
  50. package/vite.config.ts +2 -0
  51. package/dist/web/assets/api-client-BsHoRDAn.js +0 -1
  52. package/dist/web/assets/chat-tab-Bj1hZQ4x.js +0 -6
  53. package/dist/web/assets/code-editor-Bj9jdnLm.js +0 -1
  54. package/dist/web/assets/copy-BNk4Z75P.js +0 -1
  55. package/dist/web/assets/external-link-CrtbmtJ6.js +0 -1
  56. package/dist/web/assets/git-graph-DoLRBTMk.js +0 -1
  57. package/dist/web/assets/index-C_yeSRZ0.css +0 -2
  58. package/dist/web/assets/index-D27GI6gs.js +0 -21
  59. package/dist/web/assets/jsx-runtime-BFALxl05.js +0 -1
  60. package/dist/web/assets/settings-store-DWYkr_a3.js +0 -1
  61. package/dist/web/assets/settings-tab-BLoiK6Nc.js +0 -1
  62. package/dist/web/assets/tab-store-B1wzyDLQ.js +0 -1
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState, useMemo, useCallback } from "react";
2
+ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
2
3
  import { getAuthToken } from "@/lib/api-client";
3
4
  import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
+ import type { StreamingStatus } from "@/hooks/use-chat";
4
6
  import { ToolCard } from "./tool-cards";
5
7
  import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
6
8
 
@@ -8,10 +10,14 @@ import {
8
10
  AlertCircle,
9
11
  ShieldAlert,
10
12
  Bot,
13
+ ChevronDown,
14
+ ChevronRight,
11
15
  FileText,
12
16
  Image as ImageIcon,
13
17
  Copy,
14
18
  Check,
19
+ Loader2,
20
+ RotateCcw,
15
21
  TerminalSquare,
16
22
  } from "lucide-react";
17
23
 
@@ -21,7 +27,12 @@ interface MessageListProps {
21
27
  pendingApproval: { requestId: string; tool: string; input: unknown } | null;
22
28
  onApprovalResponse: (requestId: string, approved: boolean, data?: unknown) => void;
23
29
  isStreaming: boolean;
30
+ streamingStatus?: StreamingStatus;
31
+ connectingElapsed?: number;
32
+ thinkingWarningThreshold?: number;
24
33
  projectName?: string;
34
+ /** Called when user clicks Fork/Rewind — opens new forked chat tab */
35
+ onFork?: (userMessage: string) => void;
25
36
  }
26
37
 
27
38
  export function MessageList({
@@ -30,20 +41,13 @@ export function MessageList({
30
41
  pendingApproval,
31
42
  onApprovalResponse,
32
43
  isStreaming,
44
+ streamingStatus,
45
+ connectingElapsed,
46
+ thinkingWarningThreshold,
33
47
  projectName,
48
+ onFork,
34
49
  }: MessageListProps) {
35
- const bottomRef = useRef<HTMLDivElement>(null);
36
-
37
- const initialLoadRef = useRef(true);
38
-
39
- useEffect(() => {
40
- // First load: jump instantly. Subsequent updates: smooth scroll.
41
- const behavior = initialLoadRef.current ? "instant" : "smooth";
42
- bottomRef.current?.scrollIntoView({ behavior: behavior as ScrollBehavior });
43
- if (initialLoadRef.current && messages.length > 0) {
44
- initialLoadRef.current = false;
45
- }
46
- }, [messages, pendingApproval]);
50
+ // Scroll handled by StickToBottom wrapper — no manual scroll logic needed
47
51
 
48
52
  if (messagesLoading) {
49
53
  return (
@@ -64,39 +68,55 @@ export function MessageList({
64
68
  }
65
69
 
66
70
  return (
67
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
68
- {messages
69
- .filter((msg) => {
70
- // Skip empty messages: no text content AND no events
71
- const hasContent = msg.content && msg.content.trim().length > 0;
72
- const hasEvents = msg.events && msg.events.length > 0;
73
- return hasContent || hasEvents;
74
- })
75
- .map((msg) => (
76
- <MessageBubble
77
- key={msg.id}
78
- message={msg}
79
- isStreaming={isStreaming && msg.id.startsWith("streaming-")}
80
- projectName={projectName}
81
- />
82
- ))}
83
-
84
- {pendingApproval && (
85
- pendingApproval.tool === "AskUserQuestion"
86
- ? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
87
- : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
88
- )}
71
+ <StickToBottom className="flex-1 overflow-y-auto" resize="smooth" initial="instant">
72
+ <StickToBottom.Content className="p-4 space-y-4">
73
+ {messages
74
+ .filter((msg) => {
75
+ const hasContent = msg.content && msg.content.trim().length > 0;
76
+ const hasEvents = msg.events && msg.events.length > 0;
77
+ return hasContent || hasEvents;
78
+ })
79
+ .map((msg) => (
80
+ <MessageBubble
81
+ key={msg.id}
82
+ message={msg}
83
+ isStreaming={isStreaming && msg.id.startsWith("streaming-")}
84
+ projectName={projectName}
85
+ onFork={msg.role === "user" && onFork ? () => onFork(msg.content) : undefined}
86
+ />
87
+ ))}
88
+
89
+ {pendingApproval && (
90
+ pendingApproval.tool === "AskUserQuestion"
91
+ ? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
92
+ : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
93
+ )}
89
94
 
90
- {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} />}
95
+ {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} streamingStatus={streamingStatus} elapsed={connectingElapsed} warningThreshold={thinkingWarningThreshold} />}
96
+ </StickToBottom.Content>
97
+ <ScrollToBottomButton />
98
+ </StickToBottom>
99
+ );
100
+ }
91
101
 
92
- <div ref={bottomRef} />
93
- </div>
102
+ /** Floating button to scroll back to bottom when user has scrolled up */
103
+ function ScrollToBottomButton() {
104
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
105
+ if (isAtBottom) return null;
106
+ return (
107
+ <button
108
+ onClick={() => scrollToBottom()}
109
+ className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-1 px-3 py-1 rounded-full bg-surface-elevated border border-border text-xs text-text-secondary hover:text-foreground shadow-lg transition-all"
110
+ >
111
+ <ChevronDown className="size-3" />
112
+ Scroll to bottom
113
+ </button>
94
114
  );
95
115
  }
96
116
 
97
- function MessageBubble({ message, isStreaming, projectName }: { message: ChatMessage; isStreaming: boolean; projectName?: string }) {
117
+ function MessageBubble({ message, isStreaming, projectName, onFork }: { message: ChatMessage; isStreaming: boolean; projectName?: string; onFork?: () => void }) {
98
118
  if (message.role === "user") {
99
- return <UserBubble content={message.content} projectName={projectName} />;
119
+ return <UserBubble content={message.content} projectName={projectName} onFork={onFork} />;
100
120
  }
101
121
 
102
122
  if (message.role === "system") {
@@ -161,12 +181,12 @@ function isPdfPath(path: string): boolean {
161
181
  }
162
182
 
163
183
  /** User message bubble with attachment rendering */
164
- function UserBubble({ content, projectName }: { content: string; projectName?: string }) {
184
+ function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
165
185
  const { files, text } = useMemo(() => parseUserAttachments(content), [content]);
166
186
 
167
187
  return (
168
- <div className="flex justify-end">
169
- <div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary max-w-[85%] space-y-2">
188
+ <div className="flex justify-end group/user">
189
+ <div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary max-w-[85%] space-y-2 relative">
170
190
  {/* Attached files */}
171
191
  {files.length > 0 && (
172
192
  <div className="flex flex-wrap gap-2">
@@ -199,6 +219,16 @@ function UserBubble({ content, projectName }: { content: string; projectName?: s
199
219
 
200
220
  {/* Text content */}
201
221
  {text && <p className="whitespace-pre-wrap break-words">{text}</p>}
222
+ {/* Fork/Rewind button — visible on hover */}
223
+ {onFork && (
224
+ <button
225
+ onClick={onFork}
226
+ title="Retry from this message (fork session)"
227
+ className="absolute -left-8 top-1/2 -translate-y-1/2 opacity-0 group-hover/user:opacity-100 transition-opacity size-6 flex items-center justify-center rounded bg-surface border border-border text-text-subtle hover:text-text-primary hover:bg-surface-elevated"
228
+ >
229
+ <RotateCcw className="size-3" />
230
+ </button>
231
+ )}
202
232
  </div>
203
233
  </div>
204
234
  );
@@ -295,6 +325,7 @@ function AuthFileLink({ src, filename, mimeType }: { src: string; filename: stri
295
325
  */
296
326
  type EventGroup =
297
327
  | { kind: "text"; content: string }
328
+ | { kind: "thinking"; content: string }
298
329
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
299
330
 
300
331
  function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string }) {
@@ -302,9 +333,21 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
302
333
  const groups: EventGroup[] = [];
303
334
  let textBuffer = "";
304
335
 
305
- // First pass: create groups for text and tool_use events
336
+ // First pass: create groups for text, thinking, and tool_use events
337
+ let thinkingBuffer = "";
306
338
  for (let i = 0; i < events.length; i++) {
307
339
  const event = events[i]!;
340
+ if (event.type === "thinking") {
341
+ // Flush text buffer first if any
342
+ if (textBuffer) { groups.push({ kind: "text", content: textBuffer }); textBuffer = ""; }
343
+ thinkingBuffer += event.content;
344
+ continue;
345
+ }
346
+ // Flush thinking buffer when non-thinking event arrives
347
+ if (thinkingBuffer) {
348
+ groups.push({ kind: "thinking", content: thinkingBuffer });
349
+ thinkingBuffer = "";
350
+ }
308
351
  if (event.type === "text") {
309
352
  textBuffer += event.content;
310
353
  } else if (event.type === "tool_use") {
@@ -323,6 +366,9 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
323
366
  groups.push({ kind: "tool", tool: event });
324
367
  }
325
368
  }
369
+ if (thinkingBuffer) {
370
+ groups.push({ kind: "thinking", content: thinkingBuffer });
371
+ }
326
372
  if (textBuffer) {
327
373
  groups.push({ kind: "text", content: textBuffer });
328
374
  }
@@ -374,6 +420,9 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
374
420
  return (
375
421
  <>
376
422
  {groups.map((group, i) => {
423
+ if (group.kind === "thinking") {
424
+ return <ThinkingBlock key={`think-${i}`} content={group.content} isStreaming={isStreaming && i === groups.length - 1} />;
425
+ }
377
426
  if (group.kind === "text") {
378
427
  const isLast = isStreaming && i === groups.length - 1;
379
428
  return (
@@ -388,6 +437,34 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
388
437
  );
389
438
  }
390
439
 
440
+ /** Collapsible thinking block — shows Claude's reasoning, collapsed by default when done */
441
+ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming: boolean }) {
442
+ const [expanded, setExpanded] = useState(isStreaming);
443
+
444
+ // Auto-collapse when streaming finishes
445
+ useEffect(() => {
446
+ if (!isStreaming && content.length > 0) setExpanded(false);
447
+ }, [isStreaming, content.length]);
448
+
449
+ return (
450
+ <div className="rounded border border-border/50 bg-surface/30 text-xs">
451
+ <button
452
+ onClick={() => setExpanded(!expanded)}
453
+ className="flex items-center gap-2 px-2 py-1.5 w-full text-left hover:bg-surface transition-colors text-text-subtle"
454
+ >
455
+ {isStreaming ? <Loader2 className="size-3 animate-spin" /> : <ChevronRight className={`size-3 transition-transform ${expanded ? "rotate-90" : ""}`} />}
456
+ <span>Thinking{isStreaming ? "..." : ""}</span>
457
+ {!isStreaming && <span className="text-text-subtle/50 ml-auto">{content.length > 100 ? `${Math.round(content.length / 4)} tokens` : ""}</span>}
458
+ </button>
459
+ {expanded && (
460
+ <div className="px-2 pb-2 text-text-subtle/80 whitespace-pre-wrap max-h-60 overflow-y-auto text-[11px] leading-relaxed">
461
+ {content}
462
+ </div>
463
+ )}
464
+ </div>
465
+ );
466
+ }
467
+
391
468
  /**
392
469
  * Text component that renders streamed content directly.
393
470
  * WebSocket already delivers tokens incrementally — no fake animation needed.
@@ -405,34 +482,44 @@ function StreamingText({ content, animate: isStreaming, projectName }: { content
405
482
  }
406
483
 
407
484
  /**
408
- * Shows "Thinking..." when:
409
- * - No assistant message yet (waiting for first response)
410
- * - Last event is tool_use/tool_result (waiting for Claude after tool execution)
485
+ * Shows streaming status with elapsed time and warnings:
486
+ * - No assistant message: "Connecting to Claude..." with elapsed timer
487
+ * - After tool: "Processing..."
488
+ * - Text streaming: hidden
411
489
  */
412
- function ThinkingIndicator({ lastMessage }: { lastMessage?: ChatMessage }) {
413
- // No assistant message yet
414
- if (!lastMessage || lastMessage.role !== "assistant") {
415
- return (
416
- <div className="flex items-center gap-2 text-text-subtle text-sm">
417
- <span className="animate-pulse">Thinking...</span>
490
+ function ThinkingIndicator({ lastMessage, streamingStatus, elapsed, warningThreshold = 15 }: { lastMessage?: ChatMessage; streamingStatus?: StreamingStatus; elapsed?: number; warningThreshold?: number }) {
491
+ // Show "Thinking" when:
492
+ // 1. No assistant message yet (waiting for first response)
493
+ // 2. Last event is tool_use/tool_result (Claude thinking after tool execution)
494
+ // Hide when text is actively streaming (text itself is the indicator)
495
+
496
+ const isWaiting = !lastMessage || lastMessage.role !== "assistant";
497
+ // Show Thinking only after tool_result (tool finished), not tool_use (tool still running)
498
+ const isAfterTool = (() => {
499
+ if (!lastMessage?.events?.length) return false;
500
+ const last = lastMessage.events[lastMessage.events.length - 1]!;
501
+ return last.type === "tool_result";
502
+ })();
503
+
504
+ if (!isWaiting && !isAfterTool) return null;
505
+
506
+ const isLong = isWaiting && (elapsed ?? 0) >= warningThreshold;
507
+ return (
508
+ <div className="flex flex-col gap-1 text-sm">
509
+ <div className="flex items-center gap-2 text-text-subtle">
510
+ <Loader2 className="size-3 animate-spin" />
511
+ <span>
512
+ Thinking
513
+ {isWaiting && (elapsed ?? 0) > 0 && <span className="text-text-subtle/60">... ({elapsed}s)</span>}
514
+ </span>
418
515
  </div>
419
- );
420
- }
421
-
422
- // Check if last event is non-text (tool_use, tool_result) → waiting for next response
423
- const events = lastMessage.events;
424
- if (events && events.length > 0) {
425
- const lastEvent = events[events.length - 1]!;
426
- if (lastEvent?.type === "tool_use" || lastEvent?.type === "tool_result") {
427
- return (
428
- <div className="flex items-center gap-2 text-text-subtle text-sm">
429
- <span className="animate-pulse">Thinking...</span>
430
- </div>
431
- );
432
- }
433
- }
434
-
435
- return null;
516
+ {isLong && (
517
+ <p className="text-xs text-yellow-500/80 ml-5">
518
+ Taking longer than usual — may be rate-limited or API slow. Try sending a new message to retry.
519
+ </p>
520
+ )}
521
+ </div>
522
+ );
436
523
  }
437
524
 
438
525
  /** Wrapper: delegates to shared MarkdownRenderer with code actions enabled */
@@ -51,7 +51,7 @@ interface UsageDetailPanelProps {
51
51
  onClose: () => void;
52
52
  onReload?: () => void;
53
53
  loading?: boolean;
54
- lastUpdatedAt?: number | null;
54
+ lastFetchedAt?: string | null;
55
55
  }
56
56
 
57
57
  function formatResetTime(bucket?: LimitBucket): string | null {
@@ -113,7 +113,7 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
113
113
  return `${mins}m ago`;
114
114
  }
115
115
 
116
- export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastUpdatedAt }: UsageDetailPanelProps) {
116
+ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastFetchedAt }: UsageDetailPanelProps) {
117
117
  if (!visible) return null;
118
118
 
119
119
  const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
@@ -124,8 +124,8 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
124
124
  <div className="flex items-center justify-between">
125
125
  <div className="flex items-center gap-2">
126
126
  <span className="text-xs font-semibold text-text-primary">Usage Limits</span>
127
- {lastUpdatedAt && (
128
- <span className="text-[10px] text-text-subtle">{formatLastUpdated(lastUpdatedAt)}</span>
127
+ {lastFetchedAt && (
128
+ <span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
129
129
  )}
130
130
  </div>
131
131
  <div className="flex items-center gap-1">
@@ -242,19 +242,38 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
242
242
  el?.scrollIntoView({ block: "nearest" });
243
243
  }, [selectedIdx]);
244
244
 
245
+ /** Open chat tab with query as message (used by "Ask AI" fallback) */
246
+ const askAi = useCallback(() => {
247
+ if (!query.trim()) return;
248
+ const projectId = activeProject?.name ?? null;
249
+ openTab({
250
+ type: "chat",
251
+ title: "AI Chat",
252
+ projectId,
253
+ metadata: { projectName: activeProject?.name, pendingMessage: query.trim() },
254
+ closable: true,
255
+ });
256
+ onClose();
257
+ }, [query, activeProject, openTab, onClose]);
258
+
245
259
  function handleKeyDown(e: React.KeyboardEvent) {
260
+ const len = filtered.length;
246
261
  switch (e.key) {
247
262
  case "ArrowDown":
248
263
  e.preventDefault();
249
- setSelectedIdx((i) => (i + 1) % filtered.length);
264
+ if (len > 0) setSelectedIdx((i) => (i + 1) % len);
250
265
  break;
251
266
  case "ArrowUp":
252
267
  e.preventDefault();
253
- setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
268
+ if (len > 0) setSelectedIdx((i) => (i - 1 + len) % len);
254
269
  break;
255
270
  case "Enter":
256
271
  e.preventDefault();
257
- filtered[selectedIdx]?.action();
272
+ if (len > 0) {
273
+ filtered[selectedIdx]?.action();
274
+ } else if (query.trim()) {
275
+ askAi();
276
+ }
258
277
  break;
259
278
  case "Escape":
260
279
  e.preventDefault();
@@ -268,10 +287,10 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
268
287
  const pathMode = isPathQuery(query);
269
288
 
270
289
  return (
271
- <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={onClose}>
290
+ <div className="fixed inset-0 z-50 flex items-end md:items-start justify-center md:pt-[20vh]" onClick={onClose}>
272
291
  <div className="fixed inset-0 bg-black/50" />
273
292
  <div
274
- className="relative z-10 w-full max-w-md rounded-lg border border-border bg-background shadow-2xl overflow-hidden"
293
+ className="relative z-10 w-full max-w-md rounded-t-xl md:rounded-xl border border-border bg-background shadow-2xl overflow-hidden max-h-[80vh] md:max-h-none"
275
294
  onClick={(e) => e.stopPropagation()}
276
295
  onKeyDown={handleKeyDown}
277
296
  >
@@ -302,9 +321,19 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
302
321
  {/* Results */}
303
322
  <div ref={listRef} className="max-h-72 overflow-y-auto py-1">
304
323
  {filtered.length === 0 ? (
305
- <p className="px-3 py-4 text-sm text-text-subtle text-center">
306
- {fsLoading ? "Searching..." : "No results"}
307
- </p>
324
+ fsLoading ? (
325
+ <p className="px-3 py-4 text-sm text-text-subtle text-center">Searching...</p>
326
+ ) : query.trim() ? (
327
+ <button
328
+ onClick={askAi}
329
+ className="flex items-center gap-3 w-full px-3 py-3 text-sm text-left text-text-secondary hover:bg-accent/15 hover:text-text-primary transition-colors"
330
+ >
331
+ <MessageSquare className="size-4 shrink-0 text-accent" />
332
+ <span>Ask AI: <span className="text-text-primary font-medium">{query.trim().slice(0, 60)}</span></span>
333
+ </button>
334
+ ) : (
335
+ <p className="px-3 py-4 text-sm text-text-subtle text-center">No results</p>
336
+ )
308
337
  ) : (
309
338
  filtered.map((cmd, i) => {
310
339
  const Icon = cmd.icon;
@@ -34,11 +34,11 @@ export function DraggableTab({
34
34
  onDragOver={onDragOver}
35
35
  onDragEnd={onDragEnd}
36
36
  className={cn(
37
- "group flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors",
38
- "border-b-2 -mb-[1px] cursor-grab active:cursor-grabbing",
37
+ "group flex items-center gap-1 px-3 h-10 whitespace-nowrap text-xs transition-colors",
38
+ "border-b-2 -mb-px cursor-grab active:cursor-grabbing",
39
39
  isActive
40
- ? "border-primary bg-surface text-foreground"
41
- : "border-transparent text-text-secondary hover:text-foreground hover:bg-surface-elevated",
40
+ ? "border-primary text-primary"
41
+ : "border-transparent text-text-secondary hover:text-foreground",
42
42
  )}
43
43
  >
44
44
  <Icon className="size-4" />
@@ -7,7 +7,7 @@ import { useSettingsStore } from "@/stores/settings-store";
7
7
  import { FileTree } from "@/components/explorer/file-tree";
8
8
  import { GitStatusPanel } from "@/components/git/git-status-panel";
9
9
  import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
10
- import { openBugReport } from "@/lib/report-bug";
10
+ import { openBugReportPopup } from "@/lib/report-bug";
11
11
  import { cn } from "@/lib/utils";
12
12
 
13
13
  type DrawerTab = "explorer" | "git" | "history";
@@ -28,7 +28,7 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
28
28
  const version = useSettingsStore((s) => s.version);
29
29
  const [activeTab, setActiveTab] = useState<DrawerTab>("explorer");
30
30
 
31
- const handleReportBug = useCallback(() => openBugReport(version), [version]);
31
+ const handleReportBug = useCallback(() => openBugReportPopup(version), [version]);
32
32
 
33
33
  return (
34
34
  <div
@@ -10,6 +10,7 @@ import { resolveProjectColor } from "@/lib/project-palette";
10
10
  import { getProjectInitials } from "@/lib/project-avatar";
11
11
  import type { TabType } from "@/stores/tab-store";
12
12
  import { cn } from "@/lib/utils";
13
+ import { openCommandPalette } from "@/hooks/use-global-keybindings";
13
14
 
14
15
  const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
15
16
  { type: "terminal", label: "Terminal" },
@@ -139,9 +140,9 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
139
140
  })}
140
141
  </div>
141
142
 
142
- {/* Add tab button */}
143
+ {/* Add tab opens command palette */}
143
144
  <button
144
- onClick={() => setNewTabSheetOpen(true)}
145
+ onClick={() => openCommandPalette()}
145
146
  className="flex items-center justify-center size-12 shrink-0 border-t-2 border-transparent text-text-secondary"
146
147
  >
147
148
  <Plus className="size-4" />
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback } from "react";
2
2
  import { Plus, Settings, ChevronUp, ChevronDown, Pencil, Trash2, Palette, Bug } from "lucide-react";
3
- import { openBugReport } from "@/lib/report-bug";
3
+ import { openBugReportPopup } from "@/lib/report-bug";
4
4
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
5
5
  import { useTabStore } from "@/stores/tab-store";
6
6
  import { useSettingsStore } from "@/stores/settings-store";
@@ -73,7 +73,7 @@ export function ProjectBar() {
73
73
  const { projects, activeProject, setActiveProject, setProjectColor, moveProject, renameProject, deleteProject, customOrder } = useProjectStore();
74
74
  const openTab = useTabStore((s) => s.openTab);
75
75
  const version = useSettingsStore((s) => s.version);
76
- const handleReportBug = useCallback(() => openBugReport(version), [version]);
76
+ const handleReportBug = useCallback(() => openBugReportPopup(version), [version]);
77
77
 
78
78
  const ordered = resolveOrder(projects, customOrder);
79
79
  const allNames = ordered.map((p) => p.name);
@@ -141,7 +141,9 @@ export function ProjectBar() {
141
141
  }
142
142
 
143
143
  function handleSettings() {
144
- openTab({ type: "settings", title: "Settings", projectId: null, closable: true });
144
+ const { sidebarCollapsed, toggleSidebar, setSidebarActiveTab } = useSettingsStore.getState();
145
+ if (sidebarCollapsed) toggleSidebar();
146
+ setSidebarActiveTab("settings");
145
147
  }
146
148
 
147
149
  return (
@@ -77,7 +77,9 @@ export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps)
77
77
  }
78
78
 
79
79
  function handleSettings() {
80
- openTab({ type: "settings", title: "Settings", projectId: null, closable: true });
80
+ const { sidebarCollapsed, toggleSidebar, setSidebarActiveTab } = useSettingsStore.getState();
81
+ if (sidebarCollapsed) toggleSidebar();
82
+ setSidebarActiveTab("settings");
81
83
  handleClose();
82
84
  }
83
85
 
@@ -70,7 +70,7 @@ export function TabBar({ panelId }: TabBarProps) {
70
70
 
71
71
  return (
72
72
  <div
73
- className="hidden md:flex items-center h-[41px] border-b border-border bg-background"
73
+ className="hidden md:flex items-center h-10 border-b border-border bg-background"
74
74
  onDragOver={handleDragOverBar}
75
75
  onDrop={handleDrop}
76
76
  onDoubleClick={handleBarDoubleClick}
@@ -81,7 +81,7 @@ export function TabBar({ panelId }: TabBarProps) {
81
81
  ref={scrollRef}
82
82
  className="flex-1 overflow-x-auto overflow-y-hidden min-w-0 scrollbar-none"
83
83
  >
84
- <div className="flex items-center gap-0.5 px-2 py-1">
84
+ <div className="flex items-center h-10">
85
85
  {tabs.map((tab, i) => (
86
86
  <DraggableTab
87
87
  key={tab.id}
@@ -107,9 +107,9 @@ export function TabBar({ panelId }: TabBarProps) {
107
107
 
108
108
  {/* + button — inside flow, sticky when overflowing */}
109
109
  <button
110
- onClick={openCommandPalette}
110
+ onClick={() => openCommandPalette()}
111
111
  title="Open command palette (Shift+Shift)"
112
- className="flex items-center justify-center size-7 shrink-0 sticky right-1 rounded-md text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors bg-background"
112
+ className="flex items-center justify-center size-10 shrink-0 sticky right-0 border-b-2 border-transparent text-text-secondary hover:text-foreground transition-colors bg-background"
113
113
  >
114
114
  <Plus className="size-4" />
115
115
  </button>
@@ -0,0 +1,58 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Copy, ExternalLink, X, Check } from "lucide-react";
3
+ import { openGithubIssue, copyToClipboard } from "@/lib/report-bug";
4
+
5
+ export function BugReportPopup() {
6
+ const [text, setText] = useState<string | null>(null);
7
+ const [copied, setCopied] = useState(false);
8
+
9
+ useEffect(() => {
10
+ function handleOpen(e: Event) {
11
+ const body = (e as CustomEvent).detail as string;
12
+ if (body) {
13
+ setText(body);
14
+ setCopied(false);
15
+ }
16
+ }
17
+ window.addEventListener("open-bug-report", handleOpen);
18
+ return () => window.removeEventListener("open-bug-report", handleOpen);
19
+ }, []);
20
+
21
+ const close = useCallback(() => setText(null), []);
22
+
23
+ if (!text) return null;
24
+
25
+ return (
26
+ <>
27
+ <div className="fixed inset-0 z-50 bg-black/50" onClick={close} />
28
+ <div className="fixed inset-4 md:inset-auto md:top-[15%] md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-lg md:max-h-[70vh] z-50 flex flex-col bg-background border border-border rounded-lg shadow-2xl overflow-hidden">
29
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border">
30
+ <span className="text-sm font-medium">Bug Report</span>
31
+ <button onClick={close} className="p-1 rounded hover:bg-surface-elevated">
32
+ <X className="size-4" />
33
+ </button>
34
+ </div>
35
+ <pre className="flex-1 overflow-auto px-4 py-2 text-xs font-mono whitespace-pre-wrap break-all">{text}</pre>
36
+ <div className="flex gap-2 p-3 border-t border-border">
37
+ <button
38
+ onClick={async () => {
39
+ const ok = await copyToClipboard(text);
40
+ if (ok) setCopied(true);
41
+ }}
42
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-surface-elevated text-xs text-foreground hover:bg-surface transition-colors"
43
+ >
44
+ {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
45
+ {copied ? "Copied" : "Copy"}
46
+ </button>
47
+ <button
48
+ onClick={() => { openGithubIssue(text); close(); }}
49
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 transition-colors"
50
+ >
51
+ <ExternalLink className="size-3" />
52
+ Open GitHub Issue
53
+ </button>
54
+ </div>
55
+ </div>
56
+ </>
57
+ );
58
+ }