@hienlh/ppm 0.22.1 → 0.22.2
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 +11 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{accounts-settings-section-D3BylchP.js → accounts-settings-section-CLEAA_Jc.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-BLPcnhTM.js → ai-resource-editor-C4ecid2g.js} +1 -1
- package/dist/web/assets/ai-settings-section-BOwGdiyH.js +1 -0
- package/dist/web/assets/ai-settings-section-Cqz13gRt.js +1 -0
- package/dist/web/assets/{api-client-C1ZwRL4Y.js → api-client-Jn3njtNw.js} +1 -1
- package/dist/web/assets/api-client-rSFnnCj4.js +1 -0
- package/dist/web/assets/{api-settings-_7Q-ULxp.js → api-settings-BxijvPBR.js} +1 -1
- package/dist/web/assets/{api-themes-DRAERo4Q.js → api-themes-CSUarkzD.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-CiMdk3sb.js → appearance-settings-section-DbA8L-Vz.js} +1 -1
- package/dist/web/assets/{audio-preview-DsYvgKqj.js → audio-preview-CxJd8wRV.js} +1 -1
- package/dist/web/assets/{branch-review-tab-B1pFjlKM.js → branch-review-tab-CMTa2uSR.js} +1 -1
- package/dist/web/assets/chat-tab-BpdgqjSL.js +15 -0
- package/dist/web/assets/{code-editor-BJhOEsnZ.js → code-editor-B7y7-5KE.js} +3 -3
- package/dist/web/assets/{codex-daily-guard-CaCQJ4r_.js → codex-daily-guard-ChDPiXDx.js} +1 -1
- package/dist/web/assets/{conflict-editor-B7u_p49h.js → conflict-editor-Axs9Ad7A.js} +1 -1
- package/dist/web/assets/{csv-preview-CttjJU1e.js → csv-preview-DTUAW2HW.js} +1 -1
- package/dist/web/assets/{database-viewer-Ca4HxIfx.js → database-viewer-qXDiWeXj.js} +1 -1
- package/dist/web/assets/{diff-viewer-9-XjLIwa.js → diff-viewer-BtLiSpqe.js} +1 -1
- package/dist/web/assets/{docx-preview-CXuk-emm.js → docx-preview-Dxvy6h13.js} +1 -1
- package/dist/web/assets/{editor-language-service-D0DuEOs_.js → editor-language-service-D75GuLXP.js} +1 -1
- package/dist/web/assets/{explorer-body-VdCBqcZu.js → explorer-body-xg1-MhlW.js} +1 -1
- package/dist/web/assets/{explorer-window-content-BIsbl7Th.js → explorer-window-content-BeQocW3i.js} +1 -1
- package/dist/web/assets/{extension-manager-section-HkY7geS5.js → extension-manager-section-Bm2v3P-n.js} +1 -1
- package/dist/web/assets/{extension-webview-DZG8YHX9.js → extension-webview-CzxrAHyL.js} +1 -1
- package/dist/web/assets/{file-icons-BXdvggK2.js → file-icons-wS-OmEVu.js} +1 -1
- package/dist/web/assets/{file-store-ojgMXgAw.js → file-store-Ds3qJ4hC.js} +1 -1
- package/dist/web/assets/{files-settings-section-qnRtkX05.js → files-settings-section-Bd-_GeZU.js} +1 -1
- package/dist/web/assets/{format-file-meta-C77h04cZ.js → format-file-meta-PPi3ptkm.js} +1 -1
- package/dist/web/assets/{general-settings-section-BAFSZmCE.js → general-settings-section-CfNPHQUk.js} +1 -1
- package/dist/web/assets/{git-log-panel-2sGpfEla.js → git-log-panel-CtGxPzH6.js} +1 -1
- package/dist/web/assets/{git-repo-store-88Z8v-pF.js → git-repo-store-BCEPfUGT.js} +1 -1
- package/dist/web/assets/{glide-data-grid-f0hyITiF.js → glide-data-grid-iSo2vP0q.js} +1 -1
- package/dist/web/assets/{group-chat-tab-Ck6sk7yY.js → group-chat-tab-D1lx0zz-.js} +1 -1
- package/dist/web/assets/{image-preview-CzCc4_Do.js → image-preview-DeGD0HJu.js} +1 -1
- package/dist/web/assets/{index-CEZVxM4G.js → index-CN0G8uYY.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-CeLmhvu1.js → jira-watcher-section-kyifZPTv.js} +1 -1
- package/dist/web/assets/keybindings-store-L5vV8DEJ.js +1 -0
- package/dist/web/assets/{keyboard-shortcuts-section-PVz6kuh2.js → keyboard-shortcuts-section-D2uJq4x9.js} +1 -1
- package/dist/web/assets/{markdown-renderer-C140Ijk0.js → markdown-renderer-BX1Q00sC.js} +1 -1
- package/dist/web/assets/markdown-renderer-CLUux0vA.js +1 -0
- package/dist/web/assets/{media-url-yfqHpPz0.js → media-url-DKK0YHxL.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CUG_owdf.js → mobile-explorer-sheet-BHQDzpwK.js} +1 -1
- package/dist/web/assets/notification-store-BHgSy-ci.js +1 -0
- package/dist/web/assets/panel-store-D20Zuf3i.js +1 -0
- package/dist/web/assets/{pdf-preview-CmBI6E7d.js → pdf-preview-DAcfhfGm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-8ZAqEALv.js → postgres-viewer-DPRsmgP5.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-Cji-Mil-.js → ppmbot-settings-section-B5N1jHgm.js} +1 -1
- package/dist/web/assets/{problems-panel-a6_hrWvl.js → problems-panel-D_wZ4_Qg.js} +1 -1
- package/dist/web/assets/{process-table-CiIqlOIK.js → process-table-DahflyQn.js} +1 -1
- package/dist/web/assets/{project-framework-store-B70Q3dNM.js → project-framework-store-ViZgce6y.js} +1 -1
- package/dist/web/assets/{project-store-B87hLRSk.js → project-store-hNvwfqX3.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-BjI4IFgp.js → proxy-settings-section-CLg_nlxn.js} +1 -1
- package/dist/web/assets/{query-audit-section-By6qyqdB.js → query-audit-section-BrfSo0s_.js} +1 -1
- package/dist/web/assets/{remote-desktop-clipboard-notice-eXYvwgx7.js → remote-desktop-clipboard-notice-Fi6cieGM.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-Doh-fci-.js → remote-desktop-mobile-sheet-lLZ-oK73.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-Dt8Akc3p.js → remote-desktop-mobile-view-j3v-4rYa.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-t83fqbID.js → remote-desktop-readiness-gate-CeLvcX_w.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CguhZMmx.js → remote-desktop-window-content-lwbkLsTC.js} +1 -1
- package/dist/web/assets/{run-in-terminal-CdefEe_R.js → run-in-terminal-D7W4IKb9.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-BVkpW5ms.js → schedules-settings-section-C8OS0o80.js} +1 -1
- package/dist/web/assets/{send-to-chat-F207yH3u.js → send-to-chat-DNKhmHbi.js} +1 -1
- package/dist/web/assets/{settings-body-CoIxYAh6.js → settings-body-DgN22Z0W.js} +2 -2
- package/dist/web/assets/{settings-store-krC6bc79.js → settings-store-CsVSVgLp.js} +2 -2
- package/dist/web/assets/{settings-tab-7DKVUTbM.js → settings-tab-ChOs94iB.js} +1 -1
- package/dist/web/assets/{settings-window-content-3_39TeX-.js → settings-window-content-BnKMmg2a.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CQB7Yp7U.js → sql-query-editor-BJZIIQsm.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Bd7rjr1D.js → sqlite-viewer--cMeCZum.js} +1 -1
- package/dist/web/assets/{system-monitor-body-DsRXeMyU.js → system-monitor-body-CXiIrn0N.js} +2 -2
- package/dist/web/assets/system-monitor-tab-BJKrTSl0.js +1 -0
- package/dist/web/assets/{system-monitor-window-content-ClPo4-El.js → system-monitor-window-content-Dy9fZJE3.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-d2TAD4PU.js → tab-host-window-content-BezqrAwC.js} +1 -1
- package/dist/web/assets/{tab-store-OJa1GPCq.js → tab-store-krAuHQ6_.js} +1 -1
- package/dist/web/assets/{team-member-sheet-7d2Qp-ie.js → team-member-sheet-CVM2QC3H.js} +2 -2
- package/dist/web/assets/{team-member-window-content-Bctg2gmg.js → team-member-window-content-D4RYKplm.js} +1 -1
- package/dist/web/assets/{terminal-tab-0HQic25V.js → terminal-tab-jikhQLct.js} +1 -1
- package/dist/web/assets/{tool-cards-CmLLRACd.js → tool-cards-D5jCQvqn.js} +2 -2
- package/dist/web/assets/{use-blob-url-CPtCcY3i.js → use-blob-url-DMGlPCQD.js} +1 -1
- package/dist/web/assets/{use-inline-blame-sDMjcz1-.js → use-inline-blame-BF8Y1KfV.js} +1 -1
- package/dist/web/assets/{use-lsp-CBgID-EU.js → use-lsp-Bx4oVtlk.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-gvhJ_jZh.js → use-monaco-theme-DHczXLMb.js} +1 -1
- package/dist/web/assets/{use-websocket-C9wlRliB.js → use-websocket-BWZkbleJ.js} +1 -1
- package/dist/web/assets/{video-preview-CSDK5j6H.js → video-preview-D191TvX9.js} +1 -1
- package/dist/web/assets/{window-pip-registry-yD6esSju.js → window-pip-registry-D1oXq-Dq.js} +1 -1
- package/dist/web/assets/ws-auth-BG-cdrQ8.js +1 -0
- package/dist/web/index.html +13 -13
- package/dist/web/sw.js +1 -1
- package/docs/architecture/workspace-and-ui.md +13 -0
- package/package.json +1 -1
- package/src/cli/commands/init.ts +1 -0
- package/src/providers/codex-app-server/codex-history.ts +26 -7
- package/src/providers/codex-app-server/codex-provider.ts +6 -4
- package/src/server/routes/chat.ts +1 -1
- package/src/server/routes/settings.ts +11 -5
- package/src/services/config.service.ts +2 -0
- package/src/types/chat.ts +1 -1
- package/src/types/config.ts +12 -1
- package/src/web/components/chat/chat-tab.tsx +162 -38
- package/src/web/components/chat/message-input.tsx +24 -0
- package/src/web/components/chat/model-thinking-selector.tsx +25 -5
- package/src/web/components/chat/new-chat-provider-gate.tsx +68 -0
- package/src/web/components/layout/editor-panel.tsx +1 -1
- package/src/web/components/layout/reparenting-tab.tsx +4 -2
- package/src/web/components/layout/tab-bar.tsx +1 -1
- package/src/web/components/settings/ai-settings-section.tsx +36 -0
- package/src/web/hooks/use-draft.ts +25 -4
- package/src/web/hooks/use-usage.ts +6 -4
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/api-settings.ts +1 -0
- package/src/web/lib/new-chat-provider.ts +8 -0
- package/src/web/stores/panel-store.ts +46 -3
- package/dist/web/assets/ai-settings-section-BtR7rF-T.js +0 -1
- package/dist/web/assets/ai-settings-section-r9JctHNM.js +0 -1
- package/dist/web/assets/api-client-pQL53cDQ.js +0 -1
- package/dist/web/assets/chat-tab-DZagX06m.js +0 -15
- package/dist/web/assets/keybindings-store-C_3ChOEf.js +0 -1
- package/dist/web/assets/markdown-renderer-Dz7-DYUg.js +0 -1
- package/dist/web/assets/notification-store-6vib1ISy.js +0 -1
- package/dist/web/assets/panel-store-CboGJAwh.js +0 -1
- package/dist/web/assets/system-monitor-tab-DdSZgux3.js +0 -1
- package/dist/web/assets/ws-auth-CEYVQ8NH.js +0 -1
|
@@ -20,6 +20,7 @@ import { MessageInput, type ChatAttachment, type MessagePriority } from "./messa
|
|
|
20
20
|
import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
|
|
21
21
|
import { FilePicker } from "./file-picker";
|
|
22
22
|
import { ChatHistoryBar } from "./chat-history-bar";
|
|
23
|
+
import { NewChatProviderGate } from "./new-chat-provider-gate";
|
|
23
24
|
import { useDraft, type DraftAttachment } from "@/hooks/use-draft";
|
|
24
25
|
|
|
25
26
|
import type { DragEvent } from "react";
|
|
@@ -31,7 +32,24 @@ interface ChatTabProps {
|
|
|
31
32
|
tabId?: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How long a first message may wait for its session to be created, and then for the
|
|
37
|
+
* new socket to report connected, before it is handed back to the user. Generous
|
|
38
|
+
* because a codex app-server cold start alone takes ~30 s; the point is only that
|
|
39
|
+
* "forever, silently" is not an option.
|
|
40
|
+
*/
|
|
41
|
+
const SESSION_CREATE_TIMEOUT_MS = 30_000;
|
|
42
|
+
const PENDING_SEND_TIMEOUT_MS = 45_000;
|
|
43
|
+
|
|
34
44
|
export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
45
|
+
return tabId && metadata ? (
|
|
46
|
+
<NewChatProviderGate tabId={tabId} metadata={metadata}>
|
|
47
|
+
<ChatTabContent metadata={metadata} tabId={tabId} />
|
|
48
|
+
</NewChatProviderGate>
|
|
49
|
+
) : <ChatTabContent metadata={metadata} tabId={tabId} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ChatTabContent({ metadata, tabId }: ChatTabProps) {
|
|
35
53
|
const [sessionId, setSessionId] = useState<string | null>(
|
|
36
54
|
(metadata?.sessionId as string) ?? null,
|
|
37
55
|
);
|
|
@@ -72,8 +90,17 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
72
90
|
(metadata?.permissionMode as string) ?? undefined,
|
|
73
91
|
);
|
|
74
92
|
|
|
75
|
-
// Pending message to send after WS connects (replaces unreliable setTimeout)
|
|
76
|
-
|
|
93
|
+
// Pending message to send after WS connects (replaces unreliable setTimeout).
|
|
94
|
+
// `draftId` is the draft it was composed under, deleted only once the message has
|
|
95
|
+
// really been handed to the socket; until then that row is the only other copy.
|
|
96
|
+
const pendingSendRef = useRef<{
|
|
97
|
+
content: string;
|
|
98
|
+
draftId: string;
|
|
99
|
+
permissionMode?: string;
|
|
100
|
+
images?: Array<{ data: string; mediaType: string }>;
|
|
101
|
+
imagePaths?: string[];
|
|
102
|
+
} | null>(null);
|
|
103
|
+
const pendingSendTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
104
|
|
|
78
105
|
// Drag-and-drop state
|
|
79
106
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -90,16 +117,17 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
90
117
|
// Usage runs independently — auto-refreshes on interval. Scoped to this session so the
|
|
91
118
|
// account shown is the one bound to it, not whichever session ran most recently.
|
|
92
119
|
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, reloadUsage } =
|
|
93
|
-
useUsage(projectName, providerId, sessionId ?? undefined
|
|
120
|
+
useUsage(projectName, providerId, sessionId ?? undefined,
|
|
121
|
+
metadata?.pickedAccountProvider === providerId ? metadata?.pickedAccountId as string | undefined : undefined);
|
|
94
122
|
|
|
95
123
|
// Draft auto-save/restore
|
|
96
|
-
const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
|
|
124
|
+
const { draft, draftLoading, saveDraft, clearDraft, cancelPendingSave } = useDraft(projectName, sessionId);
|
|
97
125
|
|
|
98
126
|
// Load global default permission mode on mount (if no per-session override)
|
|
99
127
|
useEffect(() => {
|
|
100
128
|
if (permissionMode) return;
|
|
101
129
|
getAISettings().then((s) => {
|
|
102
|
-
const provider = s.providers[
|
|
130
|
+
const provider = s.providers[providerId];
|
|
103
131
|
setPermissionMode(provider?.permission_mode ?? "bypassPermissions");
|
|
104
132
|
}).catch(() => {});
|
|
105
133
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -291,15 +319,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
291
319
|
const primaryTeam = teamActivity?.teamNames?.[0] ?? "";
|
|
292
320
|
const { members: teamMembers } = useTeamActivityFeed(primaryTeam, !!primaryTeam);
|
|
293
321
|
|
|
294
|
-
// Flush pending message once WS connects (replaces unreliable setTimeout)
|
|
295
|
-
useEffect(() => {
|
|
296
|
-
if (isConnected && pendingSendRef.current) {
|
|
297
|
-
const { content, permissionMode: pm, images: pendingImages, imagePaths: pendingPaths } = pendingSendRef.current;
|
|
298
|
-
pendingSendRef.current = null;
|
|
299
|
-
sendMessage(content, { permissionMode: pm, ...(pendingImages?.length && { images: pendingImages }), ...(pendingPaths?.length && { imagePaths: pendingPaths }) });
|
|
300
|
-
}
|
|
301
|
-
}, [isConnected, sendMessage]);
|
|
302
|
-
|
|
303
322
|
// Auto-clear notification badge when this tab is active and document is visible.
|
|
304
323
|
// Checks ALL panels (not just focused) so split-panel scenarios also clear.
|
|
305
324
|
useEffect(() => {
|
|
@@ -335,20 +354,92 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
335
354
|
|
|
336
355
|
// Pending fork message — show in input for user to edit, not auto-send
|
|
337
356
|
const [forkDraft, setForkDraft] = useState<string | undefined>(metadata?.pendingMessage as string | undefined);
|
|
338
|
-
// Pending edit: when set, the next send forks at `anchorMsgId` and continues
|
|
339
|
-
// in THIS tab (swap sessionId) instead of opening a new tab.
|
|
340
|
-
// anchorMsgId = fork anchor (prev message); ownMsgId = the edited message itself (for highlight).
|
|
341
|
-
const [editFork, setEditFork] = useState<{ anchorMsgId?: string; ownMsgId?: string } | null>(null);
|
|
342
|
-
// Local echo of a just-sent edited message. The forked session's WS connect can
|
|
343
|
-
// be slow (codex app-server cold start ~30s), and the real optimistic message is
|
|
344
|
-
// only added after `isConnected`. Show this immediately so the edit doesn't
|
|
345
|
-
// vanish while the new session spins up; cleared once the real message arrives.
|
|
346
357
|
// True from the moment an edit is submitted until the forked session starts
|
|
347
358
|
// responding. Drives a "working" indicator so the ~10s fork + codex connect
|
|
348
359
|
// doesn't leave the user staring at a frozen screen. (No optimistic message
|
|
349
360
|
// echo — appending it to the still-visible source transcript would render the
|
|
350
361
|
// edit in the wrong place; the real message appears once the fork loads.)
|
|
351
362
|
const [editForking, setEditForking] = useState(false);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A message that could not be sent, on its way back into the composer. A nonce
|
|
366
|
+
* rather than the bare string: the text is often identical to the draft the
|
|
367
|
+
* composer was prefilled with, and a value that does not change applies nothing.
|
|
368
|
+
*/
|
|
369
|
+
const [restore, setRestore] = useState<{ text: string; nonce: number } | null>(null);
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Give an unsent message back to the user.
|
|
373
|
+
*
|
|
374
|
+
* The composer empties itself the moment Enter is pressed, and the first send of a
|
|
375
|
+
* new tab still has a session to create and a socket to open before anything leaves
|
|
376
|
+
* the browser. Whichever of those fails, the text goes back into the input — the
|
|
377
|
+
* composer re-saves it as the draft so a reload keeps it — and the failure is said
|
|
378
|
+
* out loud: a cleared input over an unchanged transcript reads as nothing having
|
|
379
|
+
* happened at all.
|
|
380
|
+
*
|
|
381
|
+
* What comes back is the full message as it would have been sent, attachment
|
|
382
|
+
* markers included. The files themselves are already uploaded and the markers name
|
|
383
|
+
* them, so re-sending still hands the model every file; the composer has no way to
|
|
384
|
+
* re-attach them as chips, and a picture-only message would otherwise come back as
|
|
385
|
+
* nothing at all.
|
|
386
|
+
*/
|
|
387
|
+
const restoreUnsentMessage = useCallback((content: string, reason: string) => {
|
|
388
|
+
setRestore({ text: content, nonce: Date.now() });
|
|
389
|
+
toast.error("Message not sent", { description: `${reason} Your text is back in the input.` });
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Drop a pending send that never got its socket; hand the text back.
|
|
394
|
+
*
|
|
395
|
+
* The draft it was composed under is cleared first: the composer re-saves the
|
|
396
|
+
* restored text under the session the tab is on now, and leaving the old row
|
|
397
|
+
* behind would prefill the next new tab with this message as well.
|
|
398
|
+
*/
|
|
399
|
+
const abandonPendingSend = useCallback((reason: string) => {
|
|
400
|
+
if (pendingSendTimerRef.current) { clearTimeout(pendingSendTimerRef.current); pendingSendTimerRef.current = null; }
|
|
401
|
+
setEditForking(false);
|
|
402
|
+
const pending = pendingSendRef.current;
|
|
403
|
+
if (!pending) return;
|
|
404
|
+
pendingSendRef.current = null;
|
|
405
|
+
clearDraft(pending.draftId);
|
|
406
|
+
restoreUnsentMessage(pending.content, reason);
|
|
407
|
+
}, [restoreUnsentMessage, clearDraft]);
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Queue a message for the moment the (new) session's socket reports connected.
|
|
411
|
+
* Bounded: a socket that never says hello would otherwise keep the text in a ref
|
|
412
|
+
* with nothing on screen, for as long as the tab lives.
|
|
413
|
+
*/
|
|
414
|
+
const queuePendingSend = useCallback((pending: NonNullable<typeof pendingSendRef.current>) => {
|
|
415
|
+
pendingSendRef.current = pending;
|
|
416
|
+
if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
|
|
417
|
+
pendingSendTimerRef.current = setTimeout(
|
|
418
|
+
() => abandonPendingSend("The chat did not connect in time."),
|
|
419
|
+
PENDING_SEND_TIMEOUT_MS,
|
|
420
|
+
);
|
|
421
|
+
}, [abandonPendingSend]);
|
|
422
|
+
|
|
423
|
+
// Flush pending message once WS connects (replaces unreliable setTimeout)
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (isConnected && pendingSendRef.current) {
|
|
426
|
+
const { content, draftId, permissionMode: pm, images: pendingImages, imagePaths: pendingPaths } = pendingSendRef.current;
|
|
427
|
+
pendingSendRef.current = null;
|
|
428
|
+
if (pendingSendTimerRef.current) { clearTimeout(pendingSendTimerRef.current); pendingSendTimerRef.current = null; }
|
|
429
|
+
sendMessage(content, { permissionMode: pm, ...(pendingImages?.length && { images: pendingImages }), ...(pendingPaths?.length && { imagePaths: pendingPaths }) });
|
|
430
|
+
clearDraft(draftId);
|
|
431
|
+
}
|
|
432
|
+
}, [isConnected, sendMessage, clearDraft]);
|
|
433
|
+
|
|
434
|
+
// A closed tab takes its pending message with it; the timer must not fire into an
|
|
435
|
+
// unmounted component.
|
|
436
|
+
useEffect(() => () => {
|
|
437
|
+
if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
|
|
438
|
+
}, []);
|
|
439
|
+
// Pending edit: when set, the next send forks at `anchorMsgId` and continues
|
|
440
|
+
// in THIS tab (swap sessionId) instead of opening a new tab.
|
|
441
|
+
// anchorMsgId = fork anchor (prev message); ownMsgId = the edited message itself (for highlight).
|
|
442
|
+
const [editFork, setEditFork] = useState<{ anchorMsgId?: string; ownMsgId?: string } | null>(null);
|
|
352
443
|
// Bumped to tell MessageInput to clear its textarea when an edit is cancelled.
|
|
353
444
|
const [clearInputSignal, setClearInputSignal] = useState(0);
|
|
354
445
|
// True while a same-tree version swap loads: versions share an identical prefix,
|
|
@@ -399,13 +490,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
399
490
|
}, [projectName, providerId]);
|
|
400
491
|
|
|
401
492
|
const handleSelectSession = useCallback((session: SessionInfo) => {
|
|
402
|
-
|
|
493
|
+
// A message still waiting for its own session's socket must not ride the
|
|
494
|
+
// connect of the one picked here: `isConnected` is session-scoped, so the flush
|
|
495
|
+
// would fire on the selected conversation and post it there.
|
|
496
|
+
abandonPendingSend("You switched to another chat before it connected.");
|
|
403
497
|
setSessionId(session.id);
|
|
404
498
|
setProviderId(session.providerId);
|
|
405
499
|
if (tabId) updateTab(tabId, { title: session.title || "Chat" });
|
|
406
500
|
// Immediately clear notification for the selected session
|
|
407
501
|
useNotificationStore.getState().clearForSession(session.id);
|
|
408
|
-
}, [tabId, updateTab]);
|
|
502
|
+
}, [tabId, updateTab, abandonPendingSend]);
|
|
409
503
|
|
|
410
504
|
/** Fork current session and open new tab with the forked session, resending userMessage */
|
|
411
505
|
const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
|
|
@@ -461,43 +555,52 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
461
555
|
// session's codex connect can take ~10s; don't await in silence.
|
|
462
556
|
setEditForking(true);
|
|
463
557
|
try {
|
|
558
|
+
// Bounded like session creation below, and with the same accepted cost: a
|
|
559
|
+
// fork the server finished after the abort is a stray sibling in the tree.
|
|
464
560
|
const forked = await api.post<{ id: string }>(
|
|
465
561
|
`${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}&mode=edit`,
|
|
466
562
|
{ messageId: anchorMsgId },
|
|
563
|
+
{ signal: AbortSignal.timeout(SESSION_CREATE_TIMEOUT_MS) },
|
|
467
564
|
);
|
|
468
565
|
// The tree gained a sibling. Swapping sessionId below refetches
|
|
469
566
|
// /messages, which carries a fresh versionMap, so the switcher's n/m
|
|
470
567
|
// counts update without any cache to invalidate.
|
|
471
568
|
// Queue the edited message — flushed by the connect effect once the WS
|
|
472
|
-
// reconnects to the forked session.
|
|
473
|
-
|
|
569
|
+
// reconnects to the forked session. The draft was composed under the
|
|
570
|
+
// source session, so that is the one to clear when it goes.
|
|
571
|
+
queuePendingSend({ content: fullContent, draftId: sessionId, permissionMode });
|
|
474
572
|
// Swap the current tab to the forked session (no new tab).
|
|
475
573
|
setStaleSwap(true);
|
|
476
574
|
if (tabId) updateTab(tabId, { metadata: { ...metadata, sessionId: forked.id } });
|
|
477
575
|
setSessionId(forked.id);
|
|
478
576
|
} catch (e) {
|
|
479
577
|
setEditForking(false);
|
|
480
|
-
const msg = (e as Error)?.
|
|
578
|
+
const msg = (e as Error)?.name === "TimeoutError"
|
|
579
|
+
? "The server did not answer in time."
|
|
580
|
+
: (e as Error)?.message || "Unknown error";
|
|
481
581
|
toast.error("Cannot edit from this message", {
|
|
482
582
|
description: msg.includes("not found") || msg.includes("Invalid upToMessageId")
|
|
483
583
|
? "The original message is no longer available in the session transcript."
|
|
484
584
|
: msg,
|
|
485
585
|
});
|
|
586
|
+
// The edited text was cleared from the composer on Enter — put it back.
|
|
587
|
+
setRestore({ text: fullContent, nonce: Date.now() });
|
|
486
588
|
}
|
|
487
589
|
},
|
|
488
|
-
[sessionId, projectName, providerId, permissionMode, tabId, updateTab, metadata],
|
|
590
|
+
[sessionId, projectName, providerId, permissionMode, tabId, updateTab, metadata, queuePendingSend],
|
|
489
591
|
);
|
|
490
592
|
|
|
491
593
|
/** Swap THIS tab to another version's session (version switcher prev/next) */
|
|
492
594
|
const handleSwitchVersion = useCallback(
|
|
493
595
|
(targetSessionId: string) => {
|
|
494
596
|
if (!targetSessionId || targetSessionId === sessionId) return;
|
|
495
|
-
|
|
597
|
+
// Same as handleSelectSession: a queued edit must not land in the version switched to.
|
|
598
|
+
abandonPendingSend("You switched to another version before it connected.");
|
|
496
599
|
setStaleSwap(true);
|
|
497
600
|
if (tabId) updateTab(tabId, { metadata: { ...metadata, sessionId: targetSessionId } });
|
|
498
601
|
setSessionId(targetSessionId);
|
|
499
602
|
},
|
|
500
|
-
[sessionId, tabId, updateTab, metadata],
|
|
603
|
+
[sessionId, tabId, updateTab, metadata, abandonPendingSend],
|
|
501
604
|
);
|
|
502
605
|
|
|
503
606
|
/** Build message content with file references and inline text snippets prepended */
|
|
@@ -559,6 +662,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
559
662
|
if (!sessionId) {
|
|
560
663
|
try {
|
|
561
664
|
const pName = projectName;
|
|
665
|
+
// Bounded: the composer is already empty by now, and a create call that hangs
|
|
666
|
+
// (a stale tunnel connection after a long idle is the usual way) would otherwise
|
|
667
|
+
// leave it empty over an unchanged transcript for as long as the tab lives.
|
|
668
|
+
// Accepted cost: a create the server completed after the abort leaves one empty
|
|
669
|
+
// session behind, which the retry does not reuse.
|
|
562
670
|
const session = await api.post<Session>(`${projectUrl(pName)}/chat/sessions`, {
|
|
563
671
|
providerId,
|
|
564
672
|
title: content.slice(0, 50),
|
|
@@ -567,20 +675,27 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
567
675
|
// The account this tab claimed on open and has been displaying since. Redeeming
|
|
568
676
|
// it here is what makes that display true rather than a guess.
|
|
569
677
|
accountId: pickedAccountId,
|
|
570
|
-
});
|
|
678
|
+
}, { signal: AbortSignal.timeout(SESSION_CREATE_TIMEOUT_MS) });
|
|
571
679
|
setSessionId(session.id);
|
|
572
680
|
setProviderId(session.providerId);
|
|
573
|
-
// Queue message — will be sent by effect when WS reports isConnected
|
|
574
|
-
|
|
681
|
+
// Queue message — will be sent by effect when WS reports isConnected. It was
|
|
682
|
+
// composed under the new-tab draft, which is what to clear once it goes.
|
|
683
|
+
queuePendingSend({ content: fullContent, draftId: "__new__", permissionMode, images, imagePaths });
|
|
575
684
|
return;
|
|
576
685
|
} catch (e) {
|
|
577
686
|
console.error("Failed to create session:", e);
|
|
687
|
+
const msg = (e as Error)?.name === "TimeoutError"
|
|
688
|
+
? "The server did not answer in time."
|
|
689
|
+
: `Could not start the chat: ${(e as Error)?.message || "unknown error"}.`;
|
|
690
|
+
restoreUnsentMessage(fullContent, msg);
|
|
578
691
|
return;
|
|
579
692
|
}
|
|
580
693
|
}
|
|
581
694
|
sendMessage(fullContent, { permissionMode, priority, ...(images.length > 0 && { images }), ...(imagePaths.length > 0 && { imagePaths }) });
|
|
695
|
+
// Only now: the message is on (or queued for) a live session's socket.
|
|
696
|
+
clearDraft();
|
|
582
697
|
},
|
|
583
|
-
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata],
|
|
698
|
+
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata, pickedAccountId, queuePendingSend, restoreUnsentMessage, clearDraft],
|
|
584
699
|
);
|
|
585
700
|
|
|
586
701
|
// Read through a ref so handleInputSend keeps a stable identity — it is passed to
|
|
@@ -588,7 +703,15 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
588
703
|
const slashItemsRef = useRef(slashItems);
|
|
589
704
|
slashItemsRef.current = slashItems;
|
|
590
705
|
|
|
591
|
-
/**
|
|
706
|
+
/**
|
|
707
|
+
* Stable wrapper for MessageInput onSend — drops the prefill and delegates.
|
|
708
|
+
*
|
|
709
|
+
* The draft is NOT deleted here. The composer has already emptied itself, so the
|
|
710
|
+
* saved draft is the only other copy of the text until the send actually happens;
|
|
711
|
+
* each send path deletes it at the moment the message reaches a socket, and puts
|
|
712
|
+
* the text back when it cannot. Only the save still waiting on its debounce is
|
|
713
|
+
* dropped, or it would land under whichever session the tab is on a second later.
|
|
714
|
+
*/
|
|
592
715
|
const handleInputSend = useCallback(
|
|
593
716
|
(content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
|
|
594
717
|
// Client-handled built-ins act on the UI, so they must not reach the SDK.
|
|
@@ -605,16 +728,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
605
728
|
}
|
|
606
729
|
|
|
607
730
|
setForkDraft(undefined);
|
|
608
|
-
|
|
731
|
+
cancelPendingSave();
|
|
609
732
|
if (editFork && sessionId && projectName) {
|
|
610
733
|
const anchor = editFork.anchorMsgId;
|
|
611
734
|
setEditFork(null);
|
|
612
735
|
void handleEditSend(buildMessageWithAttachments(content, attachments), anchor);
|
|
613
736
|
return;
|
|
614
737
|
}
|
|
615
|
-
handleSend(content, attachments, priority);
|
|
738
|
+
void handleSend(content, attachments, priority);
|
|
616
739
|
},
|
|
617
|
-
[handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
|
|
740
|
+
[handleSend, clearDraft, cancelPendingSave, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
|
|
618
741
|
);
|
|
619
742
|
|
|
620
743
|
// Past user messages for the composer's ArrowUp/Down recall. Read through a ref
|
|
@@ -894,6 +1017,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
894
1017
|
autoFocus={!(metadata?.sessionId) || !!forkDraft}
|
|
895
1018
|
initialValue={forkDraft ?? draft?.content}
|
|
896
1019
|
clearSignal={clearInputSignal}
|
|
1020
|
+
restore={restore}
|
|
897
1021
|
projectName={projectName}
|
|
898
1022
|
onSlashStateChange={handleSlashStateChange}
|
|
899
1023
|
onSlashItemsLoaded={handleSlashItemsLoaded}
|
|
@@ -95,6 +95,13 @@ interface MessageInputProps {
|
|
|
95
95
|
initialValue?: string;
|
|
96
96
|
/** Bumping this counter clears the textarea (e.g. parent cancels an edit). */
|
|
97
97
|
clearSignal?: number;
|
|
98
|
+
/**
|
|
99
|
+
* A message that could not be sent, handed back. A fresh `nonce` applies it even
|
|
100
|
+
* when `text` equals the last one — `initialValue` cannot do this, because it only
|
|
101
|
+
* reacts to a *changed* value, and the text of a failed send is often exactly the
|
|
102
|
+
* draft the composer was already prefilled with.
|
|
103
|
+
*/
|
|
104
|
+
restore?: { text: string; nonce: number } | null;
|
|
98
105
|
/** Called on content change for draft auto-save */
|
|
99
106
|
onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
|
|
100
107
|
/** Returns this session's user messages, oldest first — powers ArrowUp/Down recall */
|
|
@@ -144,6 +151,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
144
151
|
onExternalPathsConsumed,
|
|
145
152
|
initialValue,
|
|
146
153
|
clearSignal,
|
|
154
|
+
restore,
|
|
147
155
|
onContentChange,
|
|
148
156
|
getUserHistory,
|
|
149
157
|
autoFocus,
|
|
@@ -317,6 +325,22 @@ export const MessageInput = memo(function MessageInput({
|
|
|
317
325
|
if (clearSignal) writeTextareas("");
|
|
318
326
|
}, [clearSignal]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
319
327
|
|
|
328
|
+
// An unsent message coming back. The send can fail up to ~45 s after Enter, by which
|
|
329
|
+
// time the user may be typing something new — so the text goes AFTER what is there
|
|
330
|
+
// rather than over it. Reported as a content change so it is saved as the draft
|
|
331
|
+
// again; `writeTextareas` alone tells nobody.
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!restore?.nonce || !restore.text) return;
|
|
334
|
+
const current = valueRef.current;
|
|
335
|
+
const next = current.trim() ? `${current}\n\n${restore.text}` : restore.text;
|
|
336
|
+
writeTextareas(next);
|
|
337
|
+
onContentChange?.(next, attachments.filter((a) => a.status === "ready" && a.serverPath).map((a) => ({ name: a.name, path: a.serverPath! })));
|
|
338
|
+
setTimeout(() => {
|
|
339
|
+
const ta = getVisibleTextarea();
|
|
340
|
+
if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
|
|
341
|
+
}, 50);
|
|
342
|
+
}, [restore?.nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
343
|
+
|
|
320
344
|
// Auto-focus on mount when requested
|
|
321
345
|
useEffect(() => {
|
|
322
346
|
if (!autoFocus) return;
|
|
@@ -46,17 +46,36 @@ export function ModelThinkingSelector({
|
|
|
46
46
|
providerId,
|
|
47
47
|
disabled,
|
|
48
48
|
}: ModelThinkingSelectorProps) {
|
|
49
|
-
const [
|
|
49
|
+
const [modelResult, setModelResult] = useState<{
|
|
50
|
+
projectName: string;
|
|
51
|
+
providerId: string;
|
|
52
|
+
models: ModelOption[];
|
|
53
|
+
error?: boolean;
|
|
54
|
+
} | null>(null);
|
|
55
|
+
// Never render another provider/project's options, even before effects run.
|
|
56
|
+
const result = modelResult?.projectName === projectName && modelResult.providerId === providerId
|
|
57
|
+
? modelResult : null;
|
|
58
|
+
const models = result?.models ?? [];
|
|
59
|
+
const loading = Boolean(projectName && providerId && !result);
|
|
60
|
+
const modelStatus = loading ? "Loading models..." : result?.error
|
|
61
|
+
? "Unable to load models" : models.length === 0 ? "No models available" : null;
|
|
50
62
|
const [open, setOpen] = useState(false);
|
|
51
63
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
52
64
|
const isMobile = useIsMobile();
|
|
53
65
|
|
|
54
66
|
useEffect(() => {
|
|
67
|
+
let active = true;
|
|
68
|
+
setModelResult(null);
|
|
55
69
|
if (!projectName || !providerId) return;
|
|
56
70
|
api
|
|
57
71
|
.get<ModelOption[]>(`${projectUrl(projectName)}/chat/providers/${providerId}/models`)
|
|
58
|
-
.then(
|
|
59
|
-
|
|
72
|
+
.then((models) => {
|
|
73
|
+
if (active) setModelResult({ projectName, providerId, models });
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {
|
|
76
|
+
if (active) setModelResult({ projectName, providerId, models: [], error: true });
|
|
77
|
+
});
|
|
78
|
+
return () => { active = false; };
|
|
60
79
|
}, [projectName, providerId]);
|
|
61
80
|
|
|
62
81
|
useEffect(() => {
|
|
@@ -77,9 +96,9 @@ export function ModelThinkingSelector({
|
|
|
77
96
|
}, [open, isMobile]);
|
|
78
97
|
|
|
79
98
|
const current = models.find((m) => m.value === model);
|
|
80
|
-
const modelDisplay =
|
|
99
|
+
const modelDisplay = loading ? "Loading models..." : current ? shortLabel(current.label) : "Model";
|
|
81
100
|
const effortValue = effort ?? DEFAULT_EFFORT;
|
|
82
|
-
const showModelList = models.length >
|
|
101
|
+
const showModelList = models.length > 0 || Boolean(modelStatus);
|
|
83
102
|
const chipText = chipLabel(modelDisplay, effortValue);
|
|
84
103
|
|
|
85
104
|
const pick = (fn: () => void) => {
|
|
@@ -93,6 +112,7 @@ export function ModelThinkingSelector({
|
|
|
93
112
|
{showModelList && (
|
|
94
113
|
<div className="flex-1 min-w-0 py-1">
|
|
95
114
|
<SectionLabel>Model</SectionLabel>
|
|
115
|
+
{modelStatus && <div role="status" className="px-3 py-2 text-xs text-text-secondary">{modelStatus}</div>}
|
|
96
116
|
{models.map((m) => (
|
|
97
117
|
<OptionRow key={m.value} active={m.value === model} onClick={() => pick(() => onModelChange(m.value))}>
|
|
98
118
|
<span className="flex-1 truncate">{shortLabel(m.label)}</span>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { getAISettings, type AISettings } from "@/lib/api-settings";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { resolveNewChatProvider } from "@/lib/new-chat-provider";
|
|
5
|
+
import { usePanelStore } from "@/stores/panel-store";
|
|
6
|
+
|
|
7
|
+
/** Do not mount chat (or claim an account) until its initial provider is known. */
|
|
8
|
+
export function NewChatProviderGate({ tabId, metadata, children }: {
|
|
9
|
+
tabId: string;
|
|
10
|
+
metadata: Record<string, unknown>;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
const [error, setError] = useState(false);
|
|
14
|
+
const [attempt, setAttempt] = useState(0);
|
|
15
|
+
const [unavailable, setUnavailable] = useState<{
|
|
16
|
+
provider: string; settings: AISettings; alternatives: Array<{ id: string; name: string }>;
|
|
17
|
+
} | null>(null);
|
|
18
|
+
const pending = metadata.providerPending === true;
|
|
19
|
+
const source = metadata.focusedProviderOnOpen as string | undefined;
|
|
20
|
+
const projectName = metadata.projectName as string | undefined;
|
|
21
|
+
|
|
22
|
+
function selectProvider(providerId: string, settings: AISettings) {
|
|
23
|
+
const store = usePanelStore.getState();
|
|
24
|
+
const tab = store.getPanelForTab(tabId)?.tabs.find((t) => t.id === tabId);
|
|
25
|
+
if (!tab?.metadata?.providerPending) return;
|
|
26
|
+
store.updateTab(tabId, { metadata: {
|
|
27
|
+
...tab.metadata,
|
|
28
|
+
providerId,
|
|
29
|
+
providerPending: undefined,
|
|
30
|
+
focusedProviderOnOpen: undefined,
|
|
31
|
+
permissionMode: tab.metadata.permissionMode ?? settings.providers[providerId]?.permission_mode ?? "bypassPermissions",
|
|
32
|
+
} });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!pending) return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
setError(false);
|
|
39
|
+
setUnavailable(null);
|
|
40
|
+
Promise.all([
|
|
41
|
+
getAISettings(),
|
|
42
|
+
projectName ? api.get<Array<{ id: string; name: string }>>(`${projectUrl(projectName)}/chat/providers`) : Promise.resolve(null),
|
|
43
|
+
]).then(([settings, providers]) => {
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
const providerId = resolveNewChatProvider(settings, source);
|
|
46
|
+
if (providers && !providers.some((p) => p.id === providerId)) {
|
|
47
|
+
setUnavailable({ provider: providerId, settings, alternatives: providers });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
selectProvider(providerId, settings);
|
|
51
|
+
}).catch(() => { if (!cancelled) setError(true); });
|
|
52
|
+
return () => { cancelled = true; };
|
|
53
|
+
}, [pending, source, projectName, tabId, attempt]);
|
|
54
|
+
|
|
55
|
+
if (!pending) return children;
|
|
56
|
+
if (unavailable) return <div className="flex h-full flex-col items-center justify-center gap-3 p-4 text-sm text-muted-foreground" role="status">
|
|
57
|
+
<p>{unavailable.provider} is not available. Choose a provider for this chat.</p>
|
|
58
|
+
{unavailable.alternatives.map((p) => <button key={p.id} className="text-primary underline"
|
|
59
|
+
onClick={() => selectProvider(p.id, unavailable.settings)}>Use {p.name}</button>)}
|
|
60
|
+
<button className="text-primary underline" onClick={() => setAttempt((n) => n + 1)}>Check again</button>
|
|
61
|
+
</div>;
|
|
62
|
+
return <div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
|
|
63
|
+
{error ? <>
|
|
64
|
+
Could not load chat settings.
|
|
65
|
+
<button className="text-primary underline" onClick={() => setAttempt((n) => n + 1)}>Retry</button>
|
|
66
|
+
</> : "Preparing chat…"}
|
|
67
|
+
</div>;
|
|
68
|
+
}
|
|
@@ -54,7 +54,7 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
54
54
|
panelCount > 1 && "border border-transparent",
|
|
55
55
|
panelCount > 1 && isFocused && "border-primary/30",
|
|
56
56
|
)}
|
|
57
|
-
onMouseDown={() => { if (usePanelStore.getState().focusedPanelId !== panelId) usePanelStore.getState().setFocusedPanel(panelId); }}
|
|
57
|
+
onMouseDown={() => { if (usePanelStore.getState().focusedPanelId !== panelId) usePanelStore.getState().setFocusedPanel(panelId, false); }}
|
|
58
58
|
>
|
|
59
59
|
<TabBar panelId={panelId} />
|
|
60
60
|
|
|
@@ -65,9 +65,11 @@ export function ReparentingTab({
|
|
|
65
65
|
if (pid === DOCK_PANEL_ID || isWindowPanelId(pid)) return;
|
|
66
66
|
usePanelStore.getState().setFocusedPanel(pid);
|
|
67
67
|
};
|
|
68
|
-
wrapper.addEventListener("
|
|
68
|
+
wrapper.addEventListener("pointerdown", onMouseDown, true);
|
|
69
|
+
wrapper.addEventListener("focusin", onMouseDown, true);
|
|
69
70
|
return () => {
|
|
70
|
-
wrapper.removeEventListener("
|
|
71
|
+
wrapper.removeEventListener("pointerdown", onMouseDown, true);
|
|
72
|
+
wrapper.removeEventListener("focusin", onMouseDown, true);
|
|
71
73
|
wrapper.remove();
|
|
72
74
|
};
|
|
73
75
|
}, [wrapper, hiddenContainer]);
|
|
@@ -448,6 +448,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
|
|
|
448
448
|
metadata: project ? { projectName: project.name } : undefined,
|
|
449
449
|
});
|
|
450
450
|
} else {
|
|
451
|
+
usePanelStore.getState().setFocusedPanel(effectivePanelId, false);
|
|
451
452
|
openCommandPalette();
|
|
452
453
|
}
|
|
453
454
|
}}
|
|
@@ -503,4 +504,3 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
|
|
|
503
504
|
</>
|
|
504
505
|
);
|
|
505
506
|
});
|
|
506
|
-
|
|
@@ -120,6 +120,42 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
|
|
|
120
120
|
<div className={gapSize}>
|
|
121
121
|
<h3 className={`${headingSize} font-medium text-text-secondary`}>AI Settings</h3>
|
|
122
122
|
|
|
123
|
+
<div className={fieldGap}>
|
|
124
|
+
<Label htmlFor="ai-new-chat-provider" className={compact ? labelSize : undefined}>New chat provider</Label>
|
|
125
|
+
<Select
|
|
126
|
+
value={settings.new_chat_provider_mode ?? "default"}
|
|
127
|
+
disabled={saving}
|
|
128
|
+
onValueChange={(value) => handleSettingsSave({ new_chat_provider_mode: value as "default" | "follow-focus" })}
|
|
129
|
+
>
|
|
130
|
+
<SelectTrigger id="ai-new-chat-provider" className={`w-full ${compact ? "h-7 text-[11px]" : ""}`}><SelectValue /></SelectTrigger>
|
|
131
|
+
<SelectContent>
|
|
132
|
+
<SelectItem value="follow-focus">Follow last focused chat</SelectItem>
|
|
133
|
+
<SelectItem value="default">Always use default provider</SelectItem>
|
|
134
|
+
</SelectContent>
|
|
135
|
+
</Select>
|
|
136
|
+
<p className={`${compact ? "text-[9px]" : "text-[11px]"} text-muted-foreground`}>
|
|
137
|
+
{settings.new_chat_provider_mode === "follow-focus"
|
|
138
|
+
? "New tabs follow the last chat you focused in this project, across panels. Existing tabs keep their provider."
|
|
139
|
+
: "New tabs always use the default provider. Existing tabs keep their provider."}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className={fieldGap}>
|
|
144
|
+
<Label htmlFor="ai-default-provider" className={compact ? labelSize : undefined}>Default provider</Label>
|
|
145
|
+
<Select value={settings.default_provider} disabled={saving}
|
|
146
|
+
onValueChange={(default_provider) => handleSettingsSave({ default_provider })}>
|
|
147
|
+
<SelectTrigger id="ai-default-provider" className={`w-full ${compact ? "h-7 text-[11px]" : ""}`}><SelectValue /></SelectTrigger>
|
|
148
|
+
<SelectContent>
|
|
149
|
+
{providerTabs.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
|
150
|
+
</SelectContent>
|
|
151
|
+
</Select>
|
|
152
|
+
{settings.new_chat_provider_mode === "follow-focus" && (
|
|
153
|
+
<p className={`${compact ? "text-[9px]" : "text-[11px]"} text-muted-foreground`}>
|
|
154
|
+
Used when no chat has been focused in this project yet.
|
|
155
|
+
</p>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
123
159
|
<div className="flex items-start justify-between gap-3 rounded-md border border-border p-3">
|
|
124
160
|
<div className={fieldGap}>
|
|
125
161
|
<Label htmlFor="ai-share-provider-context" className={compact ? labelSize : undefined}>
|
|
@@ -86,11 +86,32 @@ export function useDraft(projectName: string, sessionId: string | null) {
|
|
|
86
86
|
[projectName],
|
|
87
87
|
);
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Drop a save that is still waiting on the debounce, without touching the server.
|
|
91
|
+
*
|
|
92
|
+
* Called at Enter. The save runs against whatever session the tab is on when the
|
|
93
|
+
* timer fires — and the first send of a new tab swaps the tab onto its new session
|
|
94
|
+
* within that second, so a save left armed would write the message just sent as the
|
|
95
|
+
* new session's draft, to reappear in the composer on the next mount as if unsent.
|
|
96
|
+
*/
|
|
97
|
+
const cancelPendingSave = useCallback(() => {
|
|
98
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
99
|
+
timerRef.current = undefined;
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear the draft once its message has actually been handed to the socket.
|
|
104
|
+
*
|
|
105
|
+
* `draftId` names the draft the message was composed under. The first send of a
|
|
106
|
+
* new tab creates the session and only then sends, so by the time the send
|
|
107
|
+
* happens `sessionRef` already points at the new session while the draft still
|
|
108
|
+
* lives under `__new__` — deleting by the current session id would leave it
|
|
109
|
+
* behind and restore it into the next new tab.
|
|
110
|
+
*/
|
|
111
|
+
const clear = useCallback((draftId?: string) => {
|
|
91
112
|
if (!projectName) return;
|
|
92
113
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
93
|
-
const id = sessionRef.current ?? "__new__";
|
|
114
|
+
const id = draftId ?? sessionRef.current ?? "__new__";
|
|
94
115
|
api
|
|
95
116
|
.del(`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(id)}`)
|
|
96
117
|
.catch(() => {});
|
|
@@ -104,5 +125,5 @@ export function useDraft(projectName: string, sessionId: string | null) {
|
|
|
104
125
|
};
|
|
105
126
|
}, []);
|
|
106
127
|
|
|
107
|
-
return { draft, draftLoading: loading, saveDraft: save, clearDraft: clear };
|
|
128
|
+
return { draft, draftLoading: loading, saveDraft: save, clearDraft: clear, cancelPendingSave };
|
|
108
129
|
}
|