From 95e3e1cf2e77a1fb8df4e74136c902486e258ba4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 17 Jul 2026 10:31:56 +0300 Subject: [PATCH] fix(chat): preserve prompt when session creation fails Keep the new-session draft open until the backend confirms that the session was created successfully. This prevents a failed request from closing the draft and discarding the user's creation context. Restore the exact submitted composer text after a new-session send failure and persist it as the draft. Only restore when the composer is still empty or unchanged, so text entered while the request is pending is not overwritten. Add regression coverage that simulates an offline session creation request and verifies that the draft remains open with its title intact. --- packages/ui/src/components/chat/ChatInput.tsx | 6 ++++ packages/ui/src/sync/session-ui-store.test.js | 29 +++++++++++++++++++ packages/ui/src/sync/session-ui-store.ts | 3 +- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index dfd79904..13a9ac65 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2355,6 +2355,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo console.error('Message send failed:', rawMessage || error); + const currentInput = textareaRef.current?.value ?? messageRef.current; + if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { + setMessage(inputSnapshot.message); + saveStoredDraft(null, inputSnapshot.message); + } + const isSoftNetworkError = normalized.includes('timeout') || normalized.includes('timed out') || diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 276385a0..725cc36e 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -283,6 +283,35 @@ describe('openNewSessionDraft project binding', () => { }); }); +describe('createSession draft lifecycle', () => { + let originalCreateSession; + + beforeEach(() => { + originalCreateSession = opencodeClient.createSession; + useSessionUIStore.setState({ + currentSessionId: null, + currentSessionDirectory: null, + newSessionDraft: { open: true, directoryOverride: '/projects/alpha', parentID: null, title: 'Draft title' }, + }); + }); + + afterEach(() => { + opencodeClient.createSession = originalCreateSession; + }); + + test('keeps the draft open when session creation fails', async () => { + opencodeClient.createSession = async () => { + throw new Error('offline'); + }; + + const session = await useSessionUIStore.getState().createSession('Draft title', '/projects/alpha'); + + expect(session).toBeNull(); + expect(useSessionUIStore.getState().newSessionDraft.open).toBe(true); + expect(useSessionUIStore.getState().newSessionDraft.title).toBe('Draft title'); + }); +}); + describe('routeMessage skill invocation', () => { // OpenCode registers every skill as a command (source: "skill"), so a skill // selected from the slash menu must be dispatched via session.command so its diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 1a2ad75e..e4f92def 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -1196,13 +1196,14 @@ export const useSessionUIStore = create()((set, get) => ({ createSession: async (title, directoryOverride, parentID, metadata) => { const draft = get().newSessionDraft const targetFolderId = draft.targetFolderId - get().closeNewSessionDraft() try { const dir = directoryOverride ?? opencodeClient.getDirectory() const session = await createSessionAction(title, dir, parentID ?? null, metadata) if (!session) return null + get().closeNewSessionDraft() + if (targetFolderId) { const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory if (scopeKey) {