@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.
- package/CHANGELOG.md +26 -2
- package/bun.lock +3 -0
- package/dist/web/assets/api-client-ANLU-Irq.js +1 -0
- package/dist/web/assets/chat-tab-C6iTYbRI.js +7 -0
- package/dist/web/assets/code-editor-hnDDc8JZ.js +1 -0
- package/dist/web/assets/{diff-viewer-B9oX4DDx.js → diff-viewer-BWeMVAvK.js} +1 -1
- package/dist/web/assets/git-graph-D6oftHHC.js +1 -0
- package/dist/web/assets/index-CWwJBtaO.js +21 -0
- package/dist/web/assets/index-jmj5f_bQ.css +2 -0
- package/dist/web/assets/{input-AESbQWjx.js → input-D-F4ITU0.js} +1 -1
- package/dist/web/assets/jsx-runtime-B4BJKQ1u.js +1 -0
- package/dist/web/assets/{markdown-renderer-DdDDhQDx.js → markdown-renderer-PHBaNQ3l.js} +2 -2
- package/dist/web/assets/react-WvgCEYPV.js +1 -0
- package/dist/web/assets/rotate-ccw-BesidNnx.js +1 -0
- package/dist/web/assets/settings-store-CGtTcr8r.js +1 -0
- package/dist/web/assets/settings-tab-BpETyigv.js +1 -0
- package/dist/web/assets/tab-store-Dq1kMOkJ.js +1 -0
- package/dist/web/assets/{terminal-tab-BeFf07MH.js → terminal-tab-BTumEYyO.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-Bb9W0CI2.js → use-monaco-theme-CsNwoeyj.js} +1 -1
- package/dist/web/index.html +8 -7
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
- package/src/cli/commands/stop.ts +8 -0
- package/src/providers/claude-agent-sdk.ts +36 -13
- package/src/server/index.ts +15 -0
- package/src/server/routes/chat.ts +31 -3
- package/src/server/ws/chat.ts +40 -0
- package/src/services/claude-usage.service.ts +51 -23
- package/src/services/tunnel.service.ts +4 -0
- package/src/types/api.ts +1 -0
- package/src/types/chat.ts +1 -0
- package/src/web/app.tsx +5 -0
- package/src/web/components/chat/chat-history-bar.tsx +15 -3
- package/src/web/components/chat/chat-tab.tsx +45 -50
- package/src/web/components/chat/message-input.tsx +116 -55
- package/src/web/components/chat/message-list.tsx +156 -69
- package/src/web/components/chat/usage-badge.tsx +4 -4
- package/src/web/components/layout/command-palette.tsx +37 -8
- package/src/web/components/layout/draggable-tab.tsx +4 -4
- package/src/web/components/layout/mobile-drawer.tsx +2 -2
- package/src/web/components/layout/mobile-nav.tsx +3 -2
- package/src/web/components/layout/project-bar.tsx +5 -3
- package/src/web/components/layout/project-bottom-sheet.tsx +3 -1
- package/src/web/components/layout/tab-bar.tsx +4 -4
- package/src/web/components/shared/bug-report-popup.tsx +58 -0
- package/src/web/hooks/use-chat.ts +63 -7
- package/src/web/hooks/use-usage.ts +15 -17
- package/src/web/lib/report-bug.ts +12 -3
- package/src/web/stores/project-store.ts +7 -1
- package/vite.config.ts +2 -0
- package/dist/web/assets/api-client-BsHoRDAn.js +0 -1
- package/dist/web/assets/chat-tab-Bj1hZQ4x.js +0 -6
- package/dist/web/assets/code-editor-Bj9jdnLm.js +0 -1
- package/dist/web/assets/copy-BNk4Z75P.js +0 -1
- package/dist/web/assets/external-link-CrtbmtJ6.js +0 -1
- package/dist/web/assets/git-graph-DoLRBTMk.js +0 -1
- package/dist/web/assets/index-C_yeSRZ0.css +0 -2
- package/dist/web/assets/index-D27GI6gs.js +0 -21
- package/dist/web/assets/jsx-runtime-BFALxl05.js +0 -1
- package/dist/web/assets/settings-store-DWYkr_a3.js +0 -1
- package/dist/web/assets/settings-tab-BLoiK6Nc.js +0 -1
- 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
|
-
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
pendingApproval
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
409
|
-
* - No assistant message
|
|
410
|
-
* -
|
|
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
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
{
|
|
128
|
-
<span className="text-[10px] text-text-subtle">{formatLastUpdated(
|
|
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) %
|
|
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 +
|
|
268
|
+
if (len > 0) setSelectedIdx((i) => (i - 1 + len) % len);
|
|
254
269
|
break;
|
|
255
270
|
case "Enter":
|
|
256
271
|
e.preventDefault();
|
|
257
|
-
|
|
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-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
38
|
-
"border-b-2 -mb-
|
|
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
|
|
41
|
-
: "border-transparent text-text-secondary hover:text-foreground
|
|
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 {
|
|
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(() =>
|
|
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
|
|
143
|
+
{/* Add tab — opens command palette */}
|
|
143
144
|
<button
|
|
144
|
-
onClick={() =>
|
|
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 {
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
+
}
|