@hienlh/ppm 0.9.80 → 0.9.82
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/.opencode/.env.example +98 -0
- package/.opencode/skills/ads-management/scripts/.env.example +13 -0
- package/.opencode/skills/ai-multimodal/.env.example +230 -0
- package/.opencode/skills/cip-design/.env.example +6 -0
- package/.opencode/skills/devops/.env.example +76 -0
- package/.opencode/skills/docs-seeker/.env.example +15 -0
- package/.opencode/skills/elevenlabs/.env.example +3 -0
- package/.opencode/skills/marketing-dashboard/.env.example +15 -0
- package/.opencode/skills/marketing-dashboard/app/.env.example +2 -0
- package/.opencode/skills/marketing-dashboard/server/.env.example +2 -0
- package/.opencode/skills/mcp-management/scripts/dist/analyze-tools.js +70 -0
- package/.opencode/skills/mcp-management/scripts/dist/cli.js +160 -0
- package/.opencode/skills/mcp-management/scripts/dist/mcp-client.js +183 -0
- package/.opencode/skills/payment-integration/scripts/.env.example +20 -0
- package/.opencode/skills/sequential-thinking/.env.example +8 -0
- package/.repomixignore +22 -0
- package/AGENTS.md +62 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +12 -0
- package/assets/skills/ppm-guide/SKILL.md +61 -0
- package/bun.lock +9 -1
- package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/web/assets/chat-tab-bS86TsT5.js +10 -0
- package/dist/web/assets/{code-editor-BFe-hnpF.js → code-editor-BaNaQ33b.js} +1 -1
- package/dist/web/assets/{database-viewer-BeY2V5QI.js → database-viewer-C5MVw8cJ.js} +1 -1
- package/dist/web/assets/{diff-viewer-D6xzs8PP.js → diff-viewer-CUbFMWVo.js} +1 -1
- package/dist/web/assets/{extension-webview-Cd1XYFXO.js → extension-webview-CwGufYEP.js} +1 -1
- package/dist/web/assets/{git-graph-D2XXpiMQ.js → git-graph-BD7A7MLo.js} +1 -1
- package/dist/web/assets/index-BYXjCNlK.css +2 -0
- package/dist/web/assets/index-CpzkPHOC.js +30 -0
- package/dist/web/assets/keybindings-store-DsaANvBz.js +1 -0
- package/dist/web/assets/markdown-renderer-C19IsITh.js +326 -0
- package/dist/web/assets/{port-forwarding-tab-B5rj_I66.js → port-forwarding-tab-BF79F1iL.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DnlqzOnm.js → postgres-viewer-_nYiO_wp.js} +1 -1
- package/dist/web/assets/{settings-tab-CNZpuPD3.js → settings-tab-C1SQMbSu.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Df2kzbPj.js → sql-query-editor-6OFvxxuN.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Cj1G70z4.js → sqlite-viewer-SNVYFXvB.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dv9A7Xe2.js → terminal-tab-BJEkmrDt.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CPfIEo8t.js → use-monaco-theme-r8FzlCWr.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +78 -0
- package/docs/project-changelog.md +29 -0
- package/docs/system-architecture.md +2 -0
- package/package.json +5 -2
- package/release-manifest.json +15784 -0
- package/scripts/check-ppm-dir-usage.sh +21 -0
- package/scripts/generate-ppm-guide.ts +92 -0
- package/src/cli/commands/init.ts +2 -1
- package/src/cli/commands/logs.ts +11 -11
- package/src/cli/commands/report.ts +3 -2
- package/src/cli/commands/restart.ts +22 -23
- package/src/cli/commands/skills-cmd.ts +123 -0
- package/src/cli/commands/status.ts +7 -8
- package/src/cli/commands/stop.ts +18 -19
- package/src/index.ts +3 -0
- package/src/lib/account-crypto.ts +12 -7
- package/src/providers/claude-agent-sdk.ts +42 -11
- package/src/server/index.ts +8 -8
- package/src/server/routes/chat.ts +4 -2
- package/src/server/routes/upgrade.ts +3 -5
- package/src/server/ws/chat.ts +31 -0
- package/src/services/cloud-ws.service.ts +6 -3
- package/src/services/cloud.service.ts +20 -19
- package/src/services/cloudflared.service.ts +13 -13
- package/src/services/config.service.ts +5 -7
- package/src/services/db.service.ts +5 -6
- package/src/services/extension-rpc-handlers.ts +2 -2
- package/src/services/extension.service.ts +9 -12
- package/src/services/ppm-dir.ts +14 -0
- package/src/services/slash-discovery/builtin-commands.ts +53 -0
- package/src/services/slash-discovery/builtin-handlers.ts +65 -0
- package/src/services/slash-discovery/definition-source.ts +27 -0
- package/src/services/slash-discovery/discover-skill-roots.ts +128 -0
- package/src/services/slash-discovery/fuzzy-search.ts +76 -0
- package/src/services/slash-discovery/index.ts +42 -0
- package/src/services/slash-discovery/resolve-overrides.ts +41 -0
- package/src/services/slash-discovery/skill-loader.ts +156 -0
- package/src/services/slash-discovery/types.ts +51 -0
- package/src/services/slash-items.service.ts +4 -182
- package/src/services/supervisor-state.ts +14 -15
- package/src/services/supervisor-stopped-page.ts +2 -4
- package/src/services/supervisor.ts +15 -15
- package/src/services/tunnel.service.ts +22 -5
- package/src/services/upgrade.service.ts +2 -3
- package/src/types/chat.ts +3 -1
- package/src/web/components/chat/chat-history-bar.tsx +2 -15
- package/src/web/components/chat/chat-tab.tsx +5 -2
- package/src/web/components/chat/message-input.tsx +48 -6
- package/src/web/components/chat/message-list.tsx +19 -5
- package/src/web/components/chat/slash-command-picker.tsx +21 -12
- package/src/web/components/layout/mobile-nav.tsx +47 -21
- package/src/web/components/layout/panel-layout.tsx +11 -0
- package/src/web/components/layout/upgrade-banner.tsx +48 -2
- package/src/web/components/shared/markdown-renderer.tsx +5 -2
- package/src/web/hooks/use-chat.ts +33 -1
- package/src/web/main.tsx +1 -0
- package/src/web/stores/panel-store.ts +25 -1
- package/src/web/styles/globals.css +14 -0
- package/dist/web/assets/chat-tab-CmSLt4tg.js +0 -10
- package/dist/web/assets/index-BtwsLrdT.css +0 -2
- package/dist/web/assets/index-D6_wwsL_.js +0 -30
- package/dist/web/assets/keybindings-store-C8ryKudw.js +0 -1
- package/dist/web/assets/markdown-renderer-xYMhd9cE.js +0 -69
|
@@ -32,7 +32,7 @@ interface MessageInputProps {
|
|
|
32
32
|
projectName?: string;
|
|
33
33
|
/** Slash picker state change */
|
|
34
34
|
onSlashStateChange?: (visible: boolean, filter: string) => void;
|
|
35
|
-
onSlashItemsLoaded?: (items: SlashItem[]) => void;
|
|
35
|
+
onSlashItemsLoaded?: (items: SlashItem[], ranked?: boolean) => void;
|
|
36
36
|
slashSelected?: SlashItem | null;
|
|
37
37
|
/** File picker state change */
|
|
38
38
|
onFileStateChange?: (visible: boolean, filter: string) => void;
|
|
@@ -83,6 +83,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
83
83
|
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
84
84
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
85
85
|
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
86
|
+
const slashRankedRef = useRef(false);
|
|
87
|
+
const slashDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
88
|
+
const slashSearchIdRef = useRef(0);
|
|
86
89
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
87
90
|
|
|
88
91
|
// Voice input (Web Speech API)
|
|
@@ -147,21 +150,29 @@ export const MessageInput = memo(function MessageInput({
|
|
|
147
150
|
useEffect(() => {
|
|
148
151
|
if (!projectName) {
|
|
149
152
|
slashItemsRef.current = [];
|
|
150
|
-
onSlashItemsLoaded?.([]);
|
|
153
|
+
onSlashItemsLoaded?.([], false);
|
|
151
154
|
return;
|
|
152
155
|
}
|
|
153
156
|
api
|
|
154
157
|
.get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items`)
|
|
155
158
|
.then((items) => {
|
|
156
159
|
slashItemsRef.current = items;
|
|
157
|
-
|
|
160
|
+
slashRankedRef.current = false;
|
|
161
|
+
onSlashItemsLoaded?.(items, false);
|
|
158
162
|
})
|
|
159
163
|
.catch(() => {
|
|
160
164
|
slashItemsRef.current = [];
|
|
161
|
-
onSlashItemsLoaded?.([]);
|
|
165
|
+
onSlashItemsLoaded?.([], false);
|
|
162
166
|
});
|
|
163
167
|
}, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
164
168
|
|
|
169
|
+
// Cleanup debounce timer on unmount
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
return () => {
|
|
172
|
+
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
165
176
|
// Fetch file tree when projectName changes
|
|
166
177
|
useEffect(() => {
|
|
167
178
|
if (!projectName) {
|
|
@@ -387,6 +398,30 @@ export const MessageInput = memo(function MessageInput({
|
|
|
387
398
|
[handleSend, permissionMode, onModeChange],
|
|
388
399
|
);
|
|
389
400
|
|
|
401
|
+
/** Debounced server-side fuzzy search for slash items */
|
|
402
|
+
const fetchSlashSearch = useCallback(
|
|
403
|
+
(query: string) => {
|
|
404
|
+
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
405
|
+
if (!projectName || !query) return;
|
|
406
|
+
const requestId = ++slashSearchIdRef.current;
|
|
407
|
+
slashDebounceRef.current = setTimeout(() => {
|
|
408
|
+
api
|
|
409
|
+
.get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items?q=${encodeURIComponent(query)}`)
|
|
410
|
+
.then((items) => {
|
|
411
|
+
if (requestId !== slashSearchIdRef.current) return; // stale response
|
|
412
|
+
slashItemsRef.current = items;
|
|
413
|
+
slashRankedRef.current = true;
|
|
414
|
+
onSlashItemsLoaded?.(items, true);
|
|
415
|
+
})
|
|
416
|
+
.catch(() => {
|
|
417
|
+
if (requestId !== slashSearchIdRef.current) return;
|
|
418
|
+
slashRankedRef.current = false;
|
|
419
|
+
});
|
|
420
|
+
}, 150);
|
|
421
|
+
},
|
|
422
|
+
[projectName, onSlashItemsLoaded],
|
|
423
|
+
);
|
|
424
|
+
|
|
390
425
|
const updatePickerState = useCallback(
|
|
391
426
|
(text: string, cursorPos: number) => {
|
|
392
427
|
const textBefore = text.slice(0, cursorPos);
|
|
@@ -394,11 +429,18 @@ export const MessageInput = memo(function MessageInput({
|
|
|
394
429
|
// Check for slash anywhere in text (after whitespace or at start)
|
|
395
430
|
const slashMatch = textBefore.match(/(?:^|\s)\/(\S*)$/);
|
|
396
431
|
if (slashMatch && slashItemsRef.current.length > 0) {
|
|
397
|
-
|
|
432
|
+
const filter = slashMatch[1] ?? "";
|
|
433
|
+
onSlashStateChange?.(true, filter);
|
|
398
434
|
onFileStateChange?.(false, "");
|
|
435
|
+
// Trigger server-side search for non-empty filter
|
|
436
|
+
if (filter) fetchSlashSearch(filter);
|
|
399
437
|
return;
|
|
400
438
|
}
|
|
401
439
|
|
|
440
|
+
// Cancel pending search when slash picker closes
|
|
441
|
+
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
442
|
+
if (slashRankedRef.current) slashRankedRef.current = false;
|
|
443
|
+
|
|
402
444
|
// Check for @ anywhere in text (after whitespace or at start)
|
|
403
445
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
404
446
|
if (atMatch && fileItemsRef.current.length > 0) {
|
|
@@ -411,7 +453,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
411
453
|
onSlashStateChange?.(false, "");
|
|
412
454
|
onFileStateChange?.(false, "");
|
|
413
455
|
},
|
|
414
|
-
[onSlashStateChange, onFileStateChange],
|
|
456
|
+
[onSlashStateChange, onFileStateChange, fetchSlashSearch],
|
|
415
457
|
);
|
|
416
458
|
|
|
417
459
|
const handleChange = useCallback(
|
|
@@ -43,6 +43,7 @@ interface MessageListProps {
|
|
|
43
43
|
phase?: SessionPhase;
|
|
44
44
|
connectingElapsed?: number;
|
|
45
45
|
statusMessage?: string | null;
|
|
46
|
+
compactStatus?: "compacting" | null;
|
|
46
47
|
projectName?: string;
|
|
47
48
|
/** Called when user clicks Fork/Rewind — opens new forked chat tab */
|
|
48
49
|
onFork?: (userMessage: string, messageId?: string) => void;
|
|
@@ -60,6 +61,7 @@ export function MessageList({
|
|
|
60
61
|
onSelectSession,
|
|
61
62
|
connectingElapsed,
|
|
62
63
|
statusMessage,
|
|
64
|
+
compactStatus,
|
|
63
65
|
projectName,
|
|
64
66
|
onFork,
|
|
65
67
|
}: MessageListProps) {
|
|
@@ -103,9 +105,9 @@ export function MessageList({
|
|
|
103
105
|
isStreaming={isStreaming && msg.id.startsWith("streaming-")}
|
|
104
106
|
projectName={projectName}
|
|
105
107
|
onFork={msg.role === "user" && onFork ? () => {
|
|
106
|
-
// Pass the
|
|
108
|
+
// Pass the SDK UUID of the previous assistant message for fork (JSONL-level message ID)
|
|
107
109
|
const prevMsg = idx > 0 ? filtered[idx - 1] : undefined;
|
|
108
|
-
onFork(msg.content, prevMsg?.id);
|
|
110
|
+
onFork(msg.content, prevMsg?.sdkUuid ?? prevMsg?.id);
|
|
109
111
|
} : undefined}
|
|
110
112
|
/>
|
|
111
113
|
))}
|
|
@@ -117,6 +119,7 @@ export function MessageList({
|
|
|
117
119
|
)}
|
|
118
120
|
|
|
119
121
|
{isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} statusMessage={statusMessage} />}
|
|
122
|
+
{!isStreaming && compactStatus === "compacting" && <ThinkingIndicator lastMessage={undefined} phase="thinking" elapsed={undefined} statusMessage="Compacting messages..." />}
|
|
120
123
|
</StickToBottom.Content>
|
|
121
124
|
<ScrollToBottomButton />
|
|
122
125
|
</StickToBottom>
|
|
@@ -675,6 +678,17 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
|
|
|
675
678
|
}
|
|
676
679
|
}
|
|
677
680
|
|
|
681
|
+
// Third pass: fallback to embedded result from buffer enrichment (reconnect).
|
|
682
|
+
// When BE buffers tool_result, it also attaches result onto the matching tool_use event.
|
|
683
|
+
for (const g of groups) {
|
|
684
|
+
if (g.kind === "tool" && !g.result && g.tool.type === "tool_use") {
|
|
685
|
+
const embedded = (g.tool as any).result;
|
|
686
|
+
if (embedded) {
|
|
687
|
+
g.result = { type: "tool_result", output: embedded.output, isError: embedded.isError } as ChatEvent;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
678
692
|
// Mark tool groups without explicit tool_result as completed when:
|
|
679
693
|
// 1. It's a Read and a later Edit on the same file has a result (Edit implies Read finished)
|
|
680
694
|
// 2. Streaming is fully finished
|
|
@@ -762,7 +776,7 @@ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming:
|
|
|
762
776
|
function StreamingText({ content, animate: isStreaming, projectName }: { content: string; animate: boolean; projectName?: string }) {
|
|
763
777
|
return (
|
|
764
778
|
<>
|
|
765
|
-
<MarkdownContent content={content} projectName={projectName} />
|
|
779
|
+
<MarkdownContent content={content} projectName={projectName} isStreaming={isStreaming} />
|
|
766
780
|
{isStreaming && (
|
|
767
781
|
<span className="text-text-subtle text-sm animate-pulse">Thinking...</span>
|
|
768
782
|
)}
|
|
@@ -826,10 +840,10 @@ function stripTeammateMessages(text: string): string {
|
|
|
826
840
|
}
|
|
827
841
|
|
|
828
842
|
/** Wrapper: delegates to shared MarkdownRenderer with code actions enabled */
|
|
829
|
-
function MarkdownContent({ content, projectName }: { content: string; projectName?: string }) {
|
|
843
|
+
function MarkdownContent({ content, projectName, isStreaming }: { content: string; projectName?: string; isStreaming?: boolean }) {
|
|
830
844
|
const cleaned = stripTeammateMessages(content);
|
|
831
845
|
if (!cleaned) return null;
|
|
832
|
-
return <MarkdownRenderer content={cleaned} projectName={projectName} codeActions />;
|
|
846
|
+
return <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />;
|
|
833
847
|
}
|
|
834
848
|
|
|
835
849
|
/* ToolCard, ToolSummary, ToolDetails extracted to ./tool-cards.tsx */
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
|
|
2
|
-
import { Sparkles, Terminal } from "lucide-react";
|
|
2
|
+
import { Sparkles, Terminal, Zap } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
export interface SlashItem {
|
|
5
|
-
type: "skill" | "command";
|
|
5
|
+
type: "skill" | "command" | "builtin";
|
|
6
6
|
name: string;
|
|
7
7
|
description: string;
|
|
8
8
|
argumentHint?: string;
|
|
9
|
-
scope?: "project" | "user";
|
|
9
|
+
scope?: "project" | "user" | "bundled";
|
|
10
|
+
category?: string;
|
|
11
|
+
aliases?: string[];
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
interface SlashCommandPickerProps {
|
|
@@ -15,6 +17,8 @@ interface SlashCommandPickerProps {
|
|
|
15
17
|
onSelect: (item: SlashItem) => void;
|
|
16
18
|
onClose: () => void;
|
|
17
19
|
visible: boolean;
|
|
20
|
+
/** When true, items are pre-ranked by server — skip client-side filtering */
|
|
21
|
+
ranked?: boolean;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function SlashCommandPicker({
|
|
@@ -23,17 +27,20 @@ export function SlashCommandPicker({
|
|
|
23
27
|
onSelect,
|
|
24
28
|
onClose,
|
|
25
29
|
visible,
|
|
30
|
+
ranked,
|
|
26
31
|
}: SlashCommandPickerProps) {
|
|
27
32
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
28
33
|
const listRef = useRef<HTMLDivElement>(null);
|
|
29
34
|
|
|
30
|
-
const filtered =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const filtered = ranked
|
|
36
|
+
? items
|
|
37
|
+
: items.filter((item) => {
|
|
38
|
+
const q = filter.toLowerCase();
|
|
39
|
+
return (
|
|
40
|
+
item.name.toLowerCase().includes(q) ||
|
|
41
|
+
item.description.toLowerCase().includes(q)
|
|
42
|
+
);
|
|
43
|
+
});
|
|
37
44
|
|
|
38
45
|
// Reset selection when filter changes
|
|
39
46
|
useEffect(() => {
|
|
@@ -105,7 +112,9 @@ export function SlashCommandPicker({
|
|
|
105
112
|
onClick={() => onSelect(item)}
|
|
106
113
|
>
|
|
107
114
|
<span className="shrink-0 mt-0.5">
|
|
108
|
-
{item.type === "
|
|
115
|
+
{item.type === "builtin" ? (
|
|
116
|
+
<Zap className="size-4 text-emerald-500" />
|
|
117
|
+
) : item.type === "skill" ? (
|
|
109
118
|
<Sparkles className="size-4 text-amber-500" />
|
|
110
119
|
) : (
|
|
111
120
|
<Terminal className="size-4 text-blue-500" />
|
|
@@ -118,7 +127,7 @@ export function SlashCommandPicker({
|
|
|
118
127
|
<span className="text-xs text-text-subtle">{item.argumentHint}</span>
|
|
119
128
|
)}
|
|
120
129
|
<span className="text-xs text-text-subtle capitalize ml-auto">
|
|
121
|
-
{item.scope === "user" ? "global" : item.type}
|
|
130
|
+
{item.scope === "bundled" ? "PPM" : item.scope === "user" ? "global" : item.type}
|
|
122
131
|
</span>
|
|
123
132
|
</div>
|
|
124
133
|
{item.description && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Terminal, MessageSquare, GitBranch, Database,
|
|
4
4
|
FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
|
|
@@ -9,7 +9,7 @@ import { useProjectStore, resolveOrder } from "@/stores/project-store";
|
|
|
9
9
|
import { findPanelPosition, MAX_ROWS } from "@/stores/panel-utils";
|
|
10
10
|
import { resolveProjectColor } from "@/lib/project-palette";
|
|
11
11
|
import { getProjectInitials } from "@/lib/project-avatar";
|
|
12
|
-
import type { TabType } from "@/stores/tab-store";
|
|
12
|
+
import type { Tab, TabType } from "@/stores/tab-store";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
14
14
|
import { openCommandPalette } from "@/hooks/use-global-keybindings";
|
|
15
15
|
import { useNotificationStore, notificationColor } from "@/stores/notification-store";
|
|
@@ -33,11 +33,27 @@ interface MobileNavProps { onMenuPress: () => void; onProjectsPress: () => void;
|
|
|
33
33
|
|
|
34
34
|
export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
35
35
|
const focusedPanelId = usePanelStore((s) => s.focusedPanelId);
|
|
36
|
-
const
|
|
37
|
-
const panelCount = usePanelStore((s) => Object.keys(s.panels).length);
|
|
36
|
+
const panels = usePanelStore((s) => s.panels);
|
|
38
37
|
const grid = usePanelStore((s) => s.grid);
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
|
|
39
|
+
// Merge tabs from all panels in grid (mobile shows single merged tab bar)
|
|
40
|
+
const { tabs, tabPanelMap } = useMemo(() => {
|
|
41
|
+
const panelIds = grid.flat();
|
|
42
|
+
const allTabs: Tab[] = [];
|
|
43
|
+
const map: Record<string, string> = {};
|
|
44
|
+
for (const pid of panelIds) {
|
|
45
|
+
const p = panels[pid];
|
|
46
|
+
if (p) {
|
|
47
|
+
for (const t of p.tabs) {
|
|
48
|
+
allTabs.push(t);
|
|
49
|
+
map[t.id] = pid;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { tabs: allTabs, tabPanelMap: map };
|
|
54
|
+
}, [panels, grid]);
|
|
55
|
+
|
|
56
|
+
const activeTabId = panels[focusedPanelId]?.activeTabId ?? null;
|
|
41
57
|
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
42
58
|
const mobileScrollRef = useRef<HTMLDivElement>(null);
|
|
43
59
|
const prevTabCount = useRef(tabs.length);
|
|
@@ -65,36 +81,46 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
65
81
|
if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; }
|
|
66
82
|
}, []);
|
|
67
83
|
|
|
68
|
-
// Context menu actions
|
|
69
|
-
const
|
|
84
|
+
// Context menu actions — use the tab's actual panel (not always focused)
|
|
85
|
+
const menuTab = menuTabId ? tabs.find((t) => t.id === menuTabId) : null;
|
|
86
|
+
const menuTabPanelId = menuTabId ? tabPanelMap[menuTabId] ?? focusedPanelId : focusedPanelId;
|
|
87
|
+
const menuTabPanelTabs = panels[menuTabPanelId]?.tabs ?? [];
|
|
88
|
+
const menuTabIdx = menuTabId ? menuTabPanelTabs.findIndex((t) => t.id === menuTabId) : -1;
|
|
89
|
+
|
|
90
|
+
const pos = findPanelPosition(grid, menuTabPanelId);
|
|
70
91
|
const canSplitDown = pos ? grid.length < MAX_ROWS : false;
|
|
71
|
-
const otherPanelIds =
|
|
92
|
+
const otherPanelIds = grid.flat().filter((id) => id !== menuTabPanelId);
|
|
72
93
|
|
|
73
94
|
function moveTabLeft(tabId: string) {
|
|
74
|
-
const
|
|
75
|
-
|
|
95
|
+
const pid = tabPanelMap[tabId] ?? focusedPanelId;
|
|
96
|
+
const pTabs = usePanelStore.getState().panels[pid]?.tabs ?? [];
|
|
97
|
+
const idx = pTabs.findIndex((t) => t.id === tabId);
|
|
98
|
+
if (idx > 0) usePanelStore.getState().reorderTab(tabId, pid, idx - 1);
|
|
76
99
|
}
|
|
77
100
|
function moveTabRight(tabId: string) {
|
|
78
|
-
const
|
|
79
|
-
|
|
101
|
+
const pid = tabPanelMap[tabId] ?? focusedPanelId;
|
|
102
|
+
const pTabs = usePanelStore.getState().panels[pid]?.tabs ?? [];
|
|
103
|
+
const idx = pTabs.findIndex((t) => t.id === tabId);
|
|
104
|
+
if (idx < pTabs.length - 1) usePanelStore.getState().reorderTab(tabId, pid, idx + 1);
|
|
80
105
|
}
|
|
81
106
|
function splitDown(tabId: string) {
|
|
82
|
-
|
|
107
|
+
const pid = tabPanelMap[tabId] ?? focusedPanelId;
|
|
108
|
+
usePanelStore.getState().splitPanel("down", tabId, pid);
|
|
83
109
|
}
|
|
84
110
|
function moveToPanel(tabId: string, targetPanelId: string) {
|
|
85
|
-
|
|
111
|
+
const pid = tabPanelMap[tabId] ?? focusedPanelId;
|
|
112
|
+
usePanelStore.getState().moveTab(tabId, pid, targetPanelId);
|
|
86
113
|
}
|
|
87
114
|
|
|
88
|
-
const menuTab = menuTabId ? tabs.find((t) => t.id === menuTabId) : null;
|
|
89
|
-
const menuTabIdx = menuTabId ? tabs.findIndex((t) => t.id === menuTabId) : -1;
|
|
90
|
-
|
|
91
115
|
const { activeProject: activeProjectForTab } = useProjectStore.getState();
|
|
92
116
|
function handleNewTab(type: TabType) {
|
|
117
|
+
const state = usePanelStore.getState();
|
|
118
|
+
const firstPanelId = state.grid[0]?.[0] ?? state.focusedPanelId;
|
|
93
119
|
const needsProject = type === "git-graph" || type === "git-diff" || type === "terminal" || type === "chat";
|
|
94
120
|
const metadata = needsProject ? { projectName: activeProjectForTab?.name } : undefined;
|
|
95
|
-
|
|
121
|
+
state.openTab(
|
|
96
122
|
{ type, title: NEW_TAB_LABELS[type] ?? type, metadata, projectId: activeProjectForTab?.name ?? null, closable: true },
|
|
97
|
-
|
|
123
|
+
firstPanelId,
|
|
98
124
|
);
|
|
99
125
|
setNewTabSheetOpen(false);
|
|
100
126
|
}
|
|
@@ -255,7 +281,7 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
255
281
|
<ArrowRight className="size-4" /> Move Right
|
|
256
282
|
</button>
|
|
257
283
|
)}
|
|
258
|
-
{canSplitDown &&
|
|
284
|
+
{canSplitDown && menuTabPanelTabs.length > 1 && (
|
|
259
285
|
<button onClick={() => { splitDown(menuTabId!); setMenuTabId(null); }}
|
|
260
286
|
className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-foreground active:bg-surface-elevated">
|
|
261
287
|
<SplitSquareVertical className="size-4" /> Split to Bottom
|
|
@@ -3,6 +3,7 @@ import { Panel, Group, Separator } from "react-resizable-panels";
|
|
|
3
3
|
import { GripVertical, GripHorizontal } from "lucide-react";
|
|
4
4
|
import { usePanelStore } from "@/stores/panel-store";
|
|
5
5
|
import { createPanel } from "@/stores/panel-utils";
|
|
6
|
+
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
6
7
|
import { EditorPanel } from "./editor-panel";
|
|
7
8
|
|
|
8
9
|
interface PanelLayoutProps {
|
|
@@ -10,9 +11,11 @@ interface PanelLayoutProps {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function PanelLayout({ projectName }: PanelLayoutProps) {
|
|
14
|
+
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
13
15
|
const grid = usePanelStore((s) =>
|
|
14
16
|
s.currentProject === projectName ? s.grid : (s.projectGrids[projectName] ?? [[]]),
|
|
15
17
|
);
|
|
18
|
+
const focusedPanelId = usePanelStore((s) => s.focusedPanelId);
|
|
16
19
|
const panelCount = grid.flat().length;
|
|
17
20
|
|
|
18
21
|
// Recover from empty grid (corrupt persisted state or edge-case bug)
|
|
@@ -29,6 +32,14 @@ export function PanelLayout({ projectName }: PanelLayoutProps) {
|
|
|
29
32
|
|
|
30
33
|
if (panelCount === 0) return null;
|
|
31
34
|
|
|
35
|
+
// Mobile: render only the focused panel (tabs are merged in MobileNav)
|
|
36
|
+
if (!isDesktop) {
|
|
37
|
+
const allPanelIds = grid.flat();
|
|
38
|
+
const panelId = allPanelIds.includes(focusedPanelId) ? focusedPanelId : allPanelIds[0];
|
|
39
|
+
if (!panelId) return null;
|
|
40
|
+
return <EditorPanel panelId={panelId} projectName={projectName} />;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
if (panelCount === 1 && grid[0]?.[0]) {
|
|
33
44
|
return <EditorPanel panelId={grid[0][0]} projectName={projectName} />;
|
|
34
45
|
}
|
|
@@ -5,6 +5,8 @@ import { Loader2, ArrowUpCircle, X } from "lucide-react";
|
|
|
5
5
|
|
|
6
6
|
const POLL_INTERVAL_MS = 60_000;
|
|
7
7
|
const DISMISS_KEY_PREFIX = "ppm-upgrade-dismissed-";
|
|
8
|
+
const RESTART_POLL_MS = 1_500;
|
|
9
|
+
const RESTART_TIMEOUT_MS = 60_000;
|
|
8
10
|
|
|
9
11
|
interface UpgradeStatus {
|
|
10
12
|
currentVersion: string;
|
|
@@ -12,6 +14,37 @@ interface UpgradeStatus {
|
|
|
12
14
|
installMethod: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
interface UpgradeResult {
|
|
18
|
+
success: boolean;
|
|
19
|
+
newVersion?: string;
|
|
20
|
+
restart: boolean;
|
|
21
|
+
message?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Poll /api/health aggressively until server goes down then back up, then reload. */
|
|
25
|
+
async function waitForServerRestart(): Promise<boolean> {
|
|
26
|
+
let serverWentDown = false;
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
|
|
29
|
+
while (Date.now() - start < RESTART_TIMEOUT_MS) {
|
|
30
|
+
await new Promise((r) => setTimeout(r, RESTART_POLL_MS));
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch("/api/health", { cache: "no-store" });
|
|
33
|
+
if (res.ok && serverWentDown) {
|
|
34
|
+
if ("caches" in window) {
|
|
35
|
+
const keys = await caches.keys();
|
|
36
|
+
await Promise.all(keys.map((k) => caches.delete(k)));
|
|
37
|
+
}
|
|
38
|
+
window.location.reload();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
serverWentDown = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
15
48
|
interface UpgradeBannerProps {
|
|
16
49
|
onVisibilityChange?: (visible: boolean) => void;
|
|
17
50
|
}
|
|
@@ -50,8 +83,21 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
|
|
|
50
83
|
const handleUpgrade = useCallback(async () => {
|
|
51
84
|
setUpgrading(true);
|
|
52
85
|
try {
|
|
53
|
-
await api.post("/api/upgrade/apply");
|
|
54
|
-
|
|
86
|
+
const data = await api.post<UpgradeResult>("/api/upgrade/apply");
|
|
87
|
+
|
|
88
|
+
if (data.restart) {
|
|
89
|
+
// Server will restart — poll aggressively until it comes back
|
|
90
|
+
const restarted = await waitForServerRestart();
|
|
91
|
+
if (!restarted) {
|
|
92
|
+
toast.warning("Upgrade installed but server hasn't restarted. Try refreshing manually.");
|
|
93
|
+
setUpgrading(false);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// No supervisor — manual restart needed
|
|
97
|
+
toast.info(data.message || "Upgrade installed. Restart PPM manually.");
|
|
98
|
+
setUpgrading(false);
|
|
99
|
+
setDismissed(true);
|
|
100
|
+
}
|
|
55
101
|
} catch (e) {
|
|
56
102
|
toast.error(`Upgrade failed: ${(e as Error).message}`);
|
|
57
103
|
setUpgrading(false);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo, useRef, useEffect } from "react";
|
|
2
2
|
import { marked } from "marked";
|
|
3
|
+
import markedKatex from "marked-katex-extension";
|
|
3
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
4
5
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
5
6
|
import { useImageOverlay } from "@/stores/image-overlay-store";
|
|
@@ -29,6 +30,7 @@ const LOCAL_PATH_RE = /^(\/|[A-Za-z]:[/\\])/;
|
|
|
29
30
|
|
|
30
31
|
// Configure marked globally
|
|
31
32
|
marked.use({ gfm: true, breaks: true });
|
|
33
|
+
marked.use(markedKatex({ throwOnError: false }));
|
|
32
34
|
|
|
33
35
|
/** Common text file extensions that PPM can open as editor tabs */
|
|
34
36
|
const FILE_EXTS = "ts|tsx|js|jsx|mjs|cjs|py|json|md|mdx|yaml|yml|toml|css|scss|less|html|htm|sh|bash|zsh|go|rs|sql|rb|java|kt|swift|c|cpp|h|hpp|cs|vue|svelte|txt|env|cfg|conf|ini|xml|csv|log|dockerfile|makefile|gradle";
|
|
@@ -41,6 +43,7 @@ interface MarkdownRendererProps {
|
|
|
41
43
|
projectName?: string;
|
|
42
44
|
className?: string;
|
|
43
45
|
codeActions?: boolean;
|
|
46
|
+
isStreaming?: boolean;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -93,7 +96,7 @@ function transformHtml(raw: string): string {
|
|
|
93
96
|
return html;
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
export function MarkdownRenderer({ content, projectName, className = "", codeActions = false }: MarkdownRendererProps) {
|
|
99
|
+
export function MarkdownRenderer({ content, projectName, className = "", codeActions = false, isStreaming = false }: MarkdownRendererProps) {
|
|
97
100
|
const html = useMemo(() => {
|
|
98
101
|
try {
|
|
99
102
|
const raw = marked.parse(content) as string;
|
|
@@ -321,7 +324,7 @@ export function MarkdownRenderer({ content, projectName, className = "", codeAct
|
|
|
321
324
|
return (
|
|
322
325
|
<div
|
|
323
326
|
ref={containerRef}
|
|
324
|
-
className={`markdown-content prose-sm ${className}`}
|
|
327
|
+
className={`markdown-content prose-sm ${isStreaming ? "is-streaming" : ""} ${className}`}
|
|
325
328
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
326
329
|
/>
|
|
327
330
|
);
|
|
@@ -4,6 +4,7 @@ import { api, getAuthToken, projectUrl } from "@/lib/api-client";
|
|
|
4
4
|
import { useNotificationStore } from "@/stores/notification-store";
|
|
5
5
|
import { usePanelStore } from "@/stores/panel-store";
|
|
6
6
|
import { playNotificationSound } from "@/lib/notification-sounds";
|
|
7
|
+
import { toast } from "sonner";
|
|
7
8
|
import type { ChatMessage, ChatEvent } from "../../types/chat";
|
|
8
9
|
import type { ChatWsServerMessage, SessionPhase } from "../../types/api";
|
|
9
10
|
|
|
@@ -91,6 +92,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
91
92
|
sessionIdRef.current = sessionId;
|
|
92
93
|
const projectNameRef = useRef(projectName);
|
|
93
94
|
projectNameRef.current = projectName;
|
|
95
|
+
/** Toast ID for the current pending approval notification */
|
|
96
|
+
const approvalToastRef = useRef<string | number | null>(null);
|
|
94
97
|
|
|
95
98
|
// Team activity tracking
|
|
96
99
|
const teamActivityRef = useRef<{
|
|
@@ -247,6 +250,29 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
247
250
|
const nType = ev.tool === "AskUserQuestion" ? "question" : "approval_request";
|
|
248
251
|
useNotificationStore.getState().addNotification(sessionIdRef.current, nType, projectNameRef.current);
|
|
249
252
|
playNotificationSound(nType);
|
|
253
|
+
// Persistent toast with action to navigate to the waiting session
|
|
254
|
+
const sid = sessionIdRef.current;
|
|
255
|
+
const isQuestion = ev.tool === "AskUserQuestion";
|
|
256
|
+
approvalToastRef.current = toast[isQuestion ? "info" : "warning"](
|
|
257
|
+
isQuestion ? "AI has a question" : `${ev.tool} needs permission`,
|
|
258
|
+
{
|
|
259
|
+
description: projectNameRef.current || `Session ${sid.slice(0, 8)}`,
|
|
260
|
+
duration: Infinity,
|
|
261
|
+
action: {
|
|
262
|
+
label: "Go to session",
|
|
263
|
+
onClick: () => {
|
|
264
|
+
const { panels } = usePanelStore.getState();
|
|
265
|
+
for (const [panelId, panel] of Object.entries(panels)) {
|
|
266
|
+
const tab = panel.tabs.find((t) => t.metadata?.sessionId === sid);
|
|
267
|
+
if (tab) {
|
|
268
|
+
usePanelStore.getState().setActiveTab(tab.id, panelId);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
);
|
|
250
276
|
}
|
|
251
277
|
break;
|
|
252
278
|
}
|
|
@@ -317,9 +343,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
317
343
|
useNotificationStore.getState().addNotification(sessionIdRef.current, "done", projectNameRef.current);
|
|
318
344
|
playNotificationSound("done");
|
|
319
345
|
}
|
|
320
|
-
// Finalize the streaming message
|
|
346
|
+
// Finalize the streaming message — preserve SDK UUID for fork/rewind
|
|
321
347
|
const finalContent = streamingContentRef.current;
|
|
322
348
|
const finalEvents = [...streamingEventsRef.current];
|
|
349
|
+
const doneUuid = ev.lastMessageUuid as string | undefined;
|
|
323
350
|
setMessages((prev) => {
|
|
324
351
|
const last = prev[prev.length - 1];
|
|
325
352
|
if (last?.role === "assistant") {
|
|
@@ -328,6 +355,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
328
355
|
id: `final-${Date.now()}`,
|
|
329
356
|
content: finalContent || last.content,
|
|
330
357
|
events: finalEvents.length > 0 ? finalEvents : last.events,
|
|
358
|
+
...(doneUuid && { sdkUuid: doneUuid }),
|
|
331
359
|
}];
|
|
332
360
|
}
|
|
333
361
|
return prev;
|
|
@@ -464,6 +492,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
464
492
|
setPhase("idle");
|
|
465
493
|
phaseRef.current = "idle";
|
|
466
494
|
setPendingApproval(null);
|
|
495
|
+
if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
|
|
467
496
|
setCompactStatus(null);
|
|
468
497
|
streamingContentRef.current = "";
|
|
469
498
|
streamingEventsRef.current = [];
|
|
@@ -548,6 +577,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
548
577
|
phaseRef.current = "thinking";
|
|
549
578
|
}
|
|
550
579
|
setPendingApproval(null);
|
|
580
|
+
if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
|
|
551
581
|
|
|
552
582
|
send(JSON.stringify({
|
|
553
583
|
type: "message",
|
|
@@ -590,6 +620,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
590
620
|
}
|
|
591
621
|
|
|
592
622
|
setPendingApproval(null);
|
|
623
|
+
if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
|
|
593
624
|
},
|
|
594
625
|
[send],
|
|
595
626
|
);
|
|
@@ -620,6 +651,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
620
651
|
setPhase("idle");
|
|
621
652
|
phaseRef.current = "idle";
|
|
622
653
|
setPendingApproval(null);
|
|
654
|
+
if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
|
|
623
655
|
}, [send]);
|
|
624
656
|
|
|
625
657
|
const reconnect = useCallback(() => {
|
package/src/web/main.tsx
CHANGED
|
@@ -209,7 +209,11 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
209
209
|
},
|
|
210
210
|
|
|
211
211
|
openTab: (tabDef, panelId?) => {
|
|
212
|
-
const
|
|
212
|
+
const mobile = get().isMobile();
|
|
213
|
+
// On mobile, always open in first panel (tabs merged in mobile nav)
|
|
214
|
+
const pid = mobile
|
|
215
|
+
? (get().grid[0]?.[0] ?? resolvePanel(panelId))
|
|
216
|
+
: resolvePanel(panelId);
|
|
213
217
|
const panel = get().panels[pid];
|
|
214
218
|
if (!panel) return "";
|
|
215
219
|
|
|
@@ -250,6 +254,26 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
|
|
257
|
+
// Mobile: dedup across all panels (merged tab bar shows all tabs)
|
|
258
|
+
if (mobile) {
|
|
259
|
+
for (const gpid of get().grid.flat()) {
|
|
260
|
+
const p = get().panels[gpid];
|
|
261
|
+
if (!p) continue;
|
|
262
|
+
const existing = p.tabs.find((t) => t.id === baseId || t.id.startsWith(`${baseId}@`));
|
|
263
|
+
if (existing) {
|
|
264
|
+
set((s) => ({
|
|
265
|
+
focusedPanelId: p.id,
|
|
266
|
+
panels: {
|
|
267
|
+
...s.panels,
|
|
268
|
+
[p.id]: { ...p, activeTabId: existing.id, tabHistory: pushHistory(p.tabHistory, existing.id) },
|
|
269
|
+
},
|
|
270
|
+
}));
|
|
271
|
+
persist();
|
|
272
|
+
return existing.id;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
253
277
|
// Non-singleton: dedup within SAME panel only
|
|
254
278
|
const currentPanel = get().panels[pid]!;
|
|
255
279
|
const existingInPanel = currentPanel.tabs.find((t) => t.id === baseId);
|