From 9e7395910b4171a0b77567599609776a13b2606b Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Mon, 10 Aug 2026 23:03:56 -0700 Subject: [PATCH 1/6] feat(web): default panel layout for new project chats (#5676) Adds two client settings under Settings -> General, both off by default: "Open files panel in new chats" and "Open terminal in new chats". When on, a new chat in a project opens with the files panel and/or the terminal drawer already visible instead of a bare chat column. The defaults are seeded at chat creation, keyed by the draft's pre-allocated thread ref, so the layout survives the draft -> thread promotion on first send. A chat that already has panel state keeps it, so a panel the user deliberately closed is never re-opened. --- .../settings/DesktopClientSettings.test.ts | 2 + .../components/settings/SettingsPanels.tsx | 63 ++++++++++++ .../src/components/settings/settingsSearch.ts | 10 ++ apps/web/src/hooks/useHandleNewThread.ts | 15 ++- apps/web/src/newThreadPanelDefaults.test.ts | 97 +++++++++++++++++++ apps/web/src/newThreadPanelDefaults.ts | 49 ++++++++++ packages/contracts/src/settings.test.ts | 24 +++++ packages/contracts/src/settings.ts | 7 ++ 8 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/newThreadPanelDefaults.test.ts create mode 100644 apps/web/src/newThreadPanelDefaults.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68..6623d7e133c4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -29,6 +29,8 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + newThreadOpenFilesPanel: false, + newThreadOpenTerminal: false, planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6743952ae26e..420acd2cf2a6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -522,6 +522,12 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.newThreadOpenFilesPanel !== DEFAULT_UNIFIED_SETTINGS.newThreadOpenFilesPanel + ? ["Open files panel in new chats"] + : []), + ...(settings.newThreadOpenTerminal !== DEFAULT_UNIFIED_SETTINGS.newThreadOpenTerminal + ? ["Open terminal in new chats"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -540,6 +546,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.newThreadOpenFilesPanel, + settings.newThreadOpenTerminal, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, @@ -644,6 +652,8 @@ export function useSettingsRestore(onRestored?: () => void) { providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + newThreadOpenFilesPanel: DEFAULT_UNIFIED_SETTINGS.newThreadOpenFilesPanel, + newThreadOpenTerminal: DEFAULT_UNIFIED_SETTINGS.newThreadOpenTerminal, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -2076,6 +2086,59 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + newThreadOpenFilesPanel: DEFAULT_UNIFIED_SETTINGS.newThreadOpenFilesPanel, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newThreadOpenFilesPanel: Boolean(checked) }) + } + aria-label="Open files panel in new chats" + /> + } + /> + + + updateSettings({ + newThreadOpenTerminal: DEFAULT_UNIFIED_SETTINGS.newThreadOpenTerminal, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newThreadOpenTerminal: Boolean(checked) }) + } + aria-label="Open terminal in new chats" + /> + } + /> + + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); +const terminalState = () => + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ); + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); + useTerminalUiStateStore.persist.clearStorage(); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, + }); +}); + +describe("applyNewThreadPanelDefaults", () => { + it("leaves both panels closed when the defaults are off", () => { + applyNewThreadPanelDefaults(threadRef, { + newThreadOpenFilesPanel: false, + newThreadOpenTerminal: false, + }); + + expect(rightPanelState()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); + expect(terminalState().terminalOpen).toBe(false); + }); + + it("opens the files panel when the files default is on", () => { + applyNewThreadPanelDefaults(threadRef, { + newThreadOpenFilesPanel: true, + newThreadOpenTerminal: false, + }); + + expect(rightPanelState()).toEqual({ + isOpen: true, + activeSurfaceId: "files", + surfaces: [{ id: "files", kind: "files" }], + }); + expect(terminalState().terminalOpen).toBe(false); + }); + + it("opens the drawer with the default terminal when the terminal default is on", () => { + applyNewThreadPanelDefaults(threadRef, { + newThreadOpenFilesPanel: false, + newThreadOpenTerminal: true, + }); + + expect(terminalState().terminalOpen).toBe(true); + expect(terminalState().terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(rightPanelState().isOpen).toBe(false); + }); + + it("opens both panels together without either clobbering the other", () => { + applyNewThreadPanelDefaults(threadRef, BOTH_ON); + + expect(rightPanelState().isOpen).toBe(true); + expect(terminalState().terminalOpen).toBe(true); + }); + + it("leaves a panel the user closed closed", () => { + useRightPanelStore.getState().open(threadRef, "diff"); + useRightPanelStore.getState().close(threadRef); + + applyNewThreadPanelDefaults(threadRef, BOTH_ON); + + expect(rightPanelState()).toEqual({ + isOpen: false, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }); + expect(terminalState().terminalOpen).toBe(false); + }); + + it("leaves a chat alone once either store holds state for it", () => { + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); + + applyNewThreadPanelDefaults(threadRef, BOTH_ON); + + expect(rightPanelState().isOpen).toBe(false); + expect(terminalState().terminalOpen).toBe(false); + }); +}); diff --git a/apps/web/src/newThreadPanelDefaults.ts b/apps/web/src/newThreadPanelDefaults.ts new file mode 100644 index 000000000000..ad118dfa6d0d --- /dev/null +++ b/apps/web/src/newThreadPanelDefaults.ts @@ -0,0 +1,49 @@ +/** + * Default panel layout for new project chats. + * + * A new chat opens with the panels the user asked for in Settings instead of a + * bare chat column. Both panel stores are keyed by the draft's pre-allocated + * thread ref, so the seeded layout carries over unchanged when the server + * thread materializes on first send. + */ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ClientSettings } from "@t3tools/contracts/settings"; + +import { useRightPanelStore } from "./rightPanelStore"; +import { useTerminalUiStateStore } from "./terminalUiStateStore"; + +export type NewThreadPanelDefaults = Pick< + ClientSettings, + "newThreadOpenFilesPanel" | "newThreadOpenTerminal" +>; + +// Both stores drop a thread's entry once it returns to the all-closed default, +// so a key present in either one means this chat already has a layout. +function hasPanelState(threadRef: ScopedThreadRef): boolean { + const threadKey = scopedThreadKey(threadRef); + return ( + threadKey in useRightPanelStore.getState().byThreadKey || + threadKey in useTerminalUiStateStore.getState().terminalUiStateByThreadKey + ); +} + +export function applyNewThreadPanelDefaults( + threadRef: ScopedThreadRef, + settings: NewThreadPanelDefaults, +): void { + if (!settings.newThreadOpenFilesPanel && !settings.newThreadOpenTerminal) return; + // "New chat" hands back an unused draft rather than minting one whenever it + // can, so the defaults must never re-force a layout: whatever that draft + // already has wins, including a panel the user deliberately closed. + if (hasPanelState(threadRef)) return; + if (settings.newThreadOpenFilesPanel) { + useRightPanelStore.getState().open(threadRef, "files"); + } + if (settings.newThreadOpenTerminal) { + // Opening with no sessions seeds the default terminal id; the drawer + // attaches it on mount, and attaching an unknown id opens the PTY in the + // project's cwd. + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); + } +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4..b87ad364afa4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -103,6 +103,30 @@ describe("ClientSettings sidebar", () => { }); }); +describe("ClientSettings new-thread panel defaults", () => { + it("defaults both new-thread panels to closed", () => { + const settings = decodeClientSettings({}); + expect(settings.newThreadOpenFilesPanel).toBe(false); + expect(settings.newThreadOpenTerminal).toBe(false); + }); + + it("preserves explicit new-thread panel opt-ins", () => { + const decoded = decodeClientSettings({ + newThreadOpenFilesPanel: true, + newThreadOpenTerminal: true, + }); + expect(decoded.newThreadOpenFilesPanel).toBe(true); + expect(decoded.newThreadOpenTerminal).toBe(true); + + const patch = decodeClientSettingsPatch({ + newThreadOpenFilesPanel: true, + newThreadOpenTerminal: true, + }); + expect(patch.newThreadOpenFilesPanel).toBe(true); + expect(patch.newThreadOpenTerminal).toBe(true); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..0e3e253c6f19 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -168,6 +168,11 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Default panel layout for new project chats: open these panels when a new + // chat is created, instead of a bare chat column. Client-local, because the + // panel state they seed is itself per-browser. + newThreadOpenFilesPanel: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + newThreadOpenTerminal: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -790,6 +795,8 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + newThreadOpenFilesPanel: Schema.optionalKey(Schema.Boolean), + newThreadOpenTerminal: Schema.optionalKey(Schema.Boolean), planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), From 4db53ed741a2dbe571379b5582a21ac0fc47b6fc Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Tue, 11 Aug 2026 01:27:53 -0700 Subject: [PATCH 2/6] fix(web): honor deliberately-closed panel layouts on new chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both panel stores drop a thread's entry once its layout returns to the all-closed default, so store contents alone could not tell an untouched draft from one the user had emptied on purpose: pressing "new chat" again reused that draft and re-opened the panel the user just closed. Decide the defaults once per chat and remember that decision, and count a suppressed terminal id — the only trace left when the last terminal is closed — as a layout the user has already shaped. --- apps/web/src/newThreadPanelDefaults.test.ts | 42 ++++++++++++++++++++- apps/web/src/newThreadPanelDefaults.ts | 26 +++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/web/src/newThreadPanelDefaults.test.ts b/apps/web/src/newThreadPanelDefaults.test.ts index 7ad1dd268047..b33d930aaba9 100644 --- a/apps/web/src/newThreadPanelDefaults.test.ts +++ b/apps/web/src/newThreadPanelDefaults.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -7,8 +7,17 @@ import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelSto import { selectThreadTerminalUiState, useTerminalUiStateStore } from "./terminalUiStateStore"; import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; -const threadRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); +let threadCounter = 0; +function nextThreadRef() { + threadCounter += 1; + return scopeThreadRef("env-1" as EnvironmentId, ThreadId.make(`thread-${threadCounter}`)); +} +// Defaults are decided once per chat for the life of the session, so each test +// needs a chat of its own. +let threadRef = nextThreadRef(); + +const FILES_ONLY = { newThreadOpenFilesPanel: true, newThreadOpenTerminal: false }; const BOTH_ON = { newThreadOpenFilesPanel: true, newThreadOpenTerminal: true }; const rightPanelState = () => @@ -20,6 +29,7 @@ const terminalState = () => ); beforeEach(() => { + threadRef = nextThreadRef(); useRightPanelStore.setState({ byThreadKey: {} }); useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ @@ -94,4 +104,32 @@ describe("applyNewThreadPanelDefaults", () => { expect(rightPanelState().isOpen).toBe(false); expect(terminalState().terminalOpen).toBe(false); }); + + it("leaves the terminal closed after the user closed the only terminal", () => { + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); + useTerminalUiStateStore.getState().closeTerminal(threadRef, DEFAULT_THREAD_TERMINAL_ID); + // Closing the last terminal returns the thread to the default UI state, which + // the store drops; the suppressed id is all that records the close. + expect( + scopedThreadKey(threadRef) in useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + ).toBe(false); + + applyNewThreadPanelDefaults(threadRef, BOTH_ON); + + expect(terminalState().terminalOpen).toBe(false); + expect(rightPanelState().isOpen).toBe(false); + }); + + it("does not re-default a chat whose layout the user emptied", () => { + applyNewThreadPanelDefaults(threadRef, FILES_ONLY); + useRightPanelStore.getState().closeSurface(threadRef, "files"); + // The all-closed entry is dropped, so the store no longer tells this chat + // apart from one that was never touched. + expect(scopedThreadKey(threadRef) in useRightPanelStore.getState().byThreadKey).toBe(false); + + // "New chat" hands back this same unused draft. + applyNewThreadPanelDefaults(threadRef, FILES_ONLY); + + expect(rightPanelState()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); + }); }); diff --git a/apps/web/src/newThreadPanelDefaults.ts b/apps/web/src/newThreadPanelDefaults.ts index ad118dfa6d0d..201295c36812 100644 --- a/apps/web/src/newThreadPanelDefaults.ts +++ b/apps/web/src/newThreadPanelDefaults.ts @@ -18,13 +18,20 @@ export type NewThreadPanelDefaults = Pick< "newThreadOpenFilesPanel" | "newThreadOpenTerminal" >; -// Both stores drop a thread's entry once it returns to the all-closed default, -// so a key present in either one means this chat already has a layout. -function hasPanelState(threadRef: ScopedThreadRef): boolean { - const threadKey = scopedThreadKey(threadRef); +// The chats this defaulting has already decided on. Both stores drop a thread's +// entry once its layout returns to the all-closed default, so store contents +// alone cannot tell an untouched chat from one the user emptied on purpose. +const decidedThreadKeys = new Set(); + +// A layout the user has already shaped: an entry in either store, or a terminal +// they closed — closing the last one drops the thread's now-default UI state but +// keeps the closed id suppressed. +function hasPanelState(threadKey: string): boolean { + const terminalUiState = useTerminalUiStateStore.getState(); return ( threadKey in useRightPanelStore.getState().byThreadKey || - threadKey in useTerminalUiStateStore.getState().terminalUiStateByThreadKey + threadKey in terminalUiState.terminalUiStateByThreadKey || + threadKey in terminalUiState.suppressedTerminalIdsByThreadKey ); } @@ -35,8 +42,13 @@ export function applyNewThreadPanelDefaults( if (!settings.newThreadOpenFilesPanel && !settings.newThreadOpenTerminal) return; // "New chat" hands back an unused draft rather than minting one whenever it // can, so the defaults must never re-force a layout: whatever that draft - // already has wins, including a panel the user deliberately closed. - if (hasPanelState(threadRef)) return; + // already has wins, including a panel the user deliberately closed. Recording + // the chat before acting on it keeps that true once the stores have forgotten + // an emptied layout — the decision is made once per chat, not once per press. + const threadKey = scopedThreadKey(threadRef); + if (decidedThreadKeys.has(threadKey)) return; + decidedThreadKeys.add(threadKey); + if (hasPanelState(threadKey)) return; if (settings.newThreadOpenFilesPanel) { useRightPanelStore.getState().open(threadRef, "files"); } From 9560bb82b52b3141a837874010fd3415efc535f6 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Tue, 11 Aug 2026 01:28:00 -0700 Subject: [PATCH 3/6] feat(web): apply new-chat panel defaults to every creation path Checking out a pull request into a chat, implementing a plan in a fresh thread, and reusing the draft already on screen each hand the user a new chat without going through the path that seeds the layout, so the setting appeared to do nothing there. Route all of them through the same defaults, which still leave a chat that has a layout alone. --- apps/web/src/components/ChatView.tsx | 20 ++++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 762dac559f21..cf10f2587385 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -180,12 +180,14 @@ import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { + getClientSettings, useClientSettings, useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { applyNewThreadPanelDefaults } from "../newThreadPanelDefaults"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; @@ -1861,6 +1863,15 @@ function ChatViewContent(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + // Each branch below hands the user a new chat in this project, so each one + // opens with the configured panel layout, exactly like the new-thread + // handler. A chat that already has a layout keeps it. + const applyPanelDefaults = (threadId: ThreadId) => { + applyNewThreadPanelDefaults( + scopeThreadRef(activeProjectRef.environmentId, threadId), + getClientSettings(), + ); + }; const logicalProjectKey = deriveLogicalProjectKeyFromSettings( activeProject, projectGroupingSettings, @@ -1877,6 +1888,7 @@ function ChatViewContent(props: ChatViewProps) { ...input, }, ); + applyPanelDefaults(storedDraftSession.threadId); if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ to: "/draft/$draftId", @@ -1900,6 +1912,7 @@ function ChatViewContent(props: ChatViewProps) { interactionMode: activeDraftSession.interactionMode, ...input, }); + applyPanelDefaults(activeDraftSession.threadId); return activeDraftSession.threadId; } @@ -1912,6 +1925,7 @@ function ChatViewContent(props: ChatViewProps) { interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); + applyPanelDefaults(nextThreadId); await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(nextDraftId), @@ -5721,6 +5735,12 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { + // The implementation runs in a chat that did not exist a moment ago, so it + // opens with the configured panel layout like any other new chat. + applyNewThreadPanelDefaults( + scopeThreadRef(activeThread.environmentId, nextThreadId), + getClientSettings(), + ); const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4ae009f79c36..b05020ab67f1 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -323,6 +323,12 @@ export function useNewThreadHandler() { interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), }); + // Presented as a new chat like the reuse path above, so it gets the + // configured default panel layout too. + applyNewThreadPanelDefaults( + scopeThreadRef(projectRef.environmentId, latestActiveDraftThread.threadId), + getClientSettings(), + ); return Promise.resolve({ draftId: currentRouteTarget.draftId, threadId: latestActiveDraftThread.threadId, From f8251970d3b6e5d54d0e4ac03fe9dfaa158f274a Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Tue, 11 Aug 2026 14:41:01 -0700 Subject: [PATCH 4/6] fix(web): wait for hydrated settings before defaulting chat panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client settings hydrate asynchronously and read as the defaults — both new-chat panels off — until that lands. The index route opens its draft as soon as projects load, well inside that window, so the first chat of a session was decided against settings that had not been read yet and nothing revisited it. Give the defaulting its own hydrated read instead of a snapshot handed in by each caller, so no call site can take the decision too early. Also record why the once-per-chat decision is kept in memory rather than persisted: an emptied layout leaves nothing behind in either store to begin with, so a durable marker would have to be threaded through the persisted composer draft schema to buy anything. --- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/hooks/useHandleNewThread.ts | 13 +-- apps/web/src/hooks/useSettings.ts | 11 ++ apps/web/src/newThreadPanelDefaults.test.ts | 106 ++++++++++++++------ apps/web/src/newThreadPanelDefaults.ts | 24 +++-- 5 files changed, 109 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf10f2587385..1c45408dd188 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -180,7 +180,6 @@ import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { - getClientSettings, useClientSettings, useClientSettingsHydrated, useEnvironmentSettings, @@ -1867,10 +1866,7 @@ function ChatViewContent(props: ChatViewProps) { // opens with the configured panel layout, exactly like the new-thread // handler. A chat that already has a layout keeps it. const applyPanelDefaults = (threadId: ThreadId) => { - applyNewThreadPanelDefaults( - scopeThreadRef(activeProjectRef.environmentId, threadId), - getClientSettings(), - ); + void applyNewThreadPanelDefaults(scopeThreadRef(activeProjectRef.environmentId, threadId)); }; const logicalProjectKey = deriveLogicalProjectKeyFromSettings( activeProject, @@ -5737,10 +5733,7 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null) { // The implementation runs in a chat that did not exist a moment ago, so it // opens with the configured panel layout like any other new chat. - applyNewThreadPanelDefaults( - scopeThreadRef(activeThread.environmentId, nextThreadId), - getClientSettings(), - ); + void applyNewThreadPanelDefaults(scopeThreadRef(activeThread.environmentId, nextThreadId)); const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b05020ab67f1..44e33368da17 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -30,7 +30,7 @@ import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefau import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { getClientSettings, useClientSettings } from "./useSettings"; +import { useClientSettings } from "./useSettings"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -272,9 +272,8 @@ export function useNewThreadHandler() { // default panel layout too — otherwise turning the preference on // appears to do nothing until the project's next real thread. A // draft that already has a layout keeps it. - applyNewThreadPanelDefaults( + void applyNewThreadPanelDefaults( scopeThreadRef(projectRef.environmentId, emptyStoredDraftThread.threadId), - getClientSettings(), ); const opened = { draftId: emptyStoredDraftThread.draftId, @@ -325,9 +324,8 @@ export function useNewThreadHandler() { }); // Presented as a new chat like the reuse path above, so it gets the // configured default panel layout too. - applyNewThreadPanelDefaults( + void applyNewThreadPanelDefaults( scopeThreadRef(projectRef.environmentId, latestActiveDraftThread.threadId), - getClientSettings(), ); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -392,10 +390,7 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); - applyNewThreadPanelDefaults( - scopeThreadRef(projectRef.environmentId, threadId), - getClientSettings(), - ); + void applyNewThreadPanelDefaults(scopeThreadRef(projectRef.environmentId, threadId)); if (carryModelSelection) { // After sticky state so the viewed thread's exact selection // (model + options like effort and context window) wins over the diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bf273879dc43..9a00bf07fe08 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -184,6 +184,17 @@ export function getClientSettings(): ClientSettings { return getClientSettingsSnapshot(); } +/** + * The client settings, once the persisted ones have landed. The snapshot reads + * as the defaults until hydration completes, so a one-shot decision taken at + * startup — rather than a subscription that re-renders on change — has to wait + * for this instead of reading {@link getClientSettings} directly. + */ +export async function getHydratedClientSettings(): Promise { + await hydrateClientSettings(); + return getClientSettingsSnapshot(); +} + export function useClientSettingsHydrated(): boolean { return useSyncExternalStore( subscribeClientSettingsHydration, diff --git a/apps/web/src/newThreadPanelDefaults.test.ts b/apps/web/src/newThreadPanelDefaults.test.ts index b33d930aaba9..fa4889d71e97 100644 --- a/apps/web/src/newThreadPanelDefaults.test.ts +++ b/apps/web/src/newThreadPanelDefaults.test.ts @@ -1,7 +1,13 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type ClientSettings, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { + __resetClientSettingsPersistenceForTests, + __setClientSettingsForTests, + getClientSettings, +} from "./hooks/useSettings"; import { applyNewThreadPanelDefaults } from "./newThreadPanelDefaults"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "./terminalUiStateStore"; @@ -17,8 +23,34 @@ function nextThreadRef() { // needs a chat of its own. let threadRef = nextThreadRef(); -const FILES_ONLY = { newThreadOpenFilesPanel: true, newThreadOpenTerminal: false }; -const BOTH_ON = { newThreadOpenFilesPanel: true, newThreadOpenTerminal: true }; +interface PanelDefaults { + newThreadOpenFilesPanel: boolean; + newThreadOpenTerminal: boolean; +} + +const BOTH_OFF: PanelDefaults = { + newThreadOpenFilesPanel: false, + newThreadOpenTerminal: false, +}; +const FILES_ONLY: PanelDefaults = { + newThreadOpenFilesPanel: true, + newThreadOpenTerminal: false, +}; +const TERMINAL_ONLY: PanelDefaults = { + newThreadOpenFilesPanel: false, + newThreadOpenTerminal: true, +}; +const BOTH_ON: PanelDefaults = { newThreadOpenFilesPanel: true, newThreadOpenTerminal: true }; + +function settingsWith(defaults: PanelDefaults): ClientSettings { + return { ...DEFAULT_CLIENT_SETTINGS, ...defaults }; +} + +// Settings that have already hydrated, which is the steady state everywhere +// except the first moments after startup. +function useSettings(defaults: PanelDefaults): void { + __setClientSettingsForTests(settingsWith(defaults)); +} const rightPanelState = () => selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); @@ -39,21 +71,19 @@ beforeEach(() => { }); describe("applyNewThreadPanelDefaults", () => { - it("leaves both panels closed when the defaults are off", () => { - applyNewThreadPanelDefaults(threadRef, { - newThreadOpenFilesPanel: false, - newThreadOpenTerminal: false, - }); + it("leaves both panels closed when the defaults are off", async () => { + useSettings(BOTH_OFF); + + await applyNewThreadPanelDefaults(threadRef); expect(rightPanelState()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); expect(terminalState().terminalOpen).toBe(false); }); - it("opens the files panel when the files default is on", () => { - applyNewThreadPanelDefaults(threadRef, { - newThreadOpenFilesPanel: true, - newThreadOpenTerminal: false, - }); + it("opens the files panel when the files default is on", async () => { + useSettings(FILES_ONLY); + + await applyNewThreadPanelDefaults(threadRef); expect(rightPanelState()).toEqual({ isOpen: true, @@ -63,29 +93,46 @@ describe("applyNewThreadPanelDefaults", () => { expect(terminalState().terminalOpen).toBe(false); }); - it("opens the drawer with the default terminal when the terminal default is on", () => { - applyNewThreadPanelDefaults(threadRef, { - newThreadOpenFilesPanel: false, - newThreadOpenTerminal: true, - }); + it("opens the drawer with the default terminal when the terminal default is on", async () => { + useSettings(TERMINAL_ONLY); + + await applyNewThreadPanelDefaults(threadRef); expect(terminalState().terminalOpen).toBe(true); expect(terminalState().terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); expect(rightPanelState().isOpen).toBe(false); }); - it("opens both panels together without either clobbering the other", () => { - applyNewThreadPanelDefaults(threadRef, BOTH_ON); + it("opens both panels together without either clobbering the other", async () => { + useSettings(BOTH_ON); + + await applyNewThreadPanelDefaults(threadRef); + + expect(rightPanelState().isOpen).toBe(true); + expect(terminalState().terminalOpen).toBe(true); + }); + + it("applies the persisted defaults to a chat opened before settings hydrate", async () => { + // The index route opens its draft as soon as projects load, which can beat + // the settings read; until that lands the snapshot says both panels are off. + __resetClientSettingsPersistenceForTests(); + expect(getClientSettings().newThreadOpenFilesPanel).toBe(false); + + const applied = applyNewThreadPanelDefaults(threadRef); + // The persisted opt-ins land while the chat is already open. + useSettings(BOTH_ON); + await applied; expect(rightPanelState().isOpen).toBe(true); expect(terminalState().terminalOpen).toBe(true); }); - it("leaves a panel the user closed closed", () => { + it("leaves a panel the user closed closed", async () => { + useSettings(BOTH_ON); useRightPanelStore.getState().open(threadRef, "diff"); useRightPanelStore.getState().close(threadRef); - applyNewThreadPanelDefaults(threadRef, BOTH_ON); + await applyNewThreadPanelDefaults(threadRef); expect(rightPanelState()).toEqual({ isOpen: false, @@ -95,17 +142,19 @@ describe("applyNewThreadPanelDefaults", () => { expect(terminalState().terminalOpen).toBe(false); }); - it("leaves a chat alone once either store holds state for it", () => { + it("leaves a chat alone once either store holds state for it", async () => { + useSettings(BOTH_ON); useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); - applyNewThreadPanelDefaults(threadRef, BOTH_ON); + await applyNewThreadPanelDefaults(threadRef); expect(rightPanelState().isOpen).toBe(false); expect(terminalState().terminalOpen).toBe(false); }); - it("leaves the terminal closed after the user closed the only terminal", () => { + it("leaves the terminal closed after the user closed the only terminal", async () => { + useSettings(BOTH_ON); useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); useTerminalUiStateStore.getState().closeTerminal(threadRef, DEFAULT_THREAD_TERMINAL_ID); // Closing the last terminal returns the thread to the default UI state, which @@ -114,21 +163,22 @@ describe("applyNewThreadPanelDefaults", () => { scopedThreadKey(threadRef) in useTerminalUiStateStore.getState().terminalUiStateByThreadKey, ).toBe(false); - applyNewThreadPanelDefaults(threadRef, BOTH_ON); + await applyNewThreadPanelDefaults(threadRef); expect(terminalState().terminalOpen).toBe(false); expect(rightPanelState().isOpen).toBe(false); }); - it("does not re-default a chat whose layout the user emptied", () => { - applyNewThreadPanelDefaults(threadRef, FILES_ONLY); + it("does not re-default a chat whose layout the user emptied", async () => { + useSettings(FILES_ONLY); + await applyNewThreadPanelDefaults(threadRef); useRightPanelStore.getState().closeSurface(threadRef, "files"); // The all-closed entry is dropped, so the store no longer tells this chat // apart from one that was never touched. expect(scopedThreadKey(threadRef) in useRightPanelStore.getState().byThreadKey).toBe(false); // "New chat" hands back this same unused draft. - applyNewThreadPanelDefaults(threadRef, FILES_ONLY); + await applyNewThreadPanelDefaults(threadRef); expect(rightPanelState()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); }); diff --git a/apps/web/src/newThreadPanelDefaults.ts b/apps/web/src/newThreadPanelDefaults.ts index 201295c36812..358ab79a6342 100644 --- a/apps/web/src/newThreadPanelDefaults.ts +++ b/apps/web/src/newThreadPanelDefaults.ts @@ -8,19 +8,21 @@ */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { ClientSettings } from "@t3tools/contracts/settings"; +import { getHydratedClientSettings } from "./hooks/useSettings"; import { useRightPanelStore } from "./rightPanelStore"; import { useTerminalUiStateStore } from "./terminalUiStateStore"; -export type NewThreadPanelDefaults = Pick< - ClientSettings, - "newThreadOpenFilesPanel" | "newThreadOpenTerminal" ->; - // The chats this defaulting has already decided on. Both stores drop a thread's // entry once its layout returns to the all-closed default, so store contents // alone cannot tell an untouched chat from one the user emptied on purpose. +// +// The record is per session. A draft the user emptied and then reloaded is +// defaulted one more time, because nothing else survives that reload either: +// the stores persist the emptied layout as no entry at all, and the terminal +// store keeps its closed ids out of what it persists. Holding the decision +// durably would mean carrying a marker per draft through the persisted composer +// draft schema, which is a bigger change than the miss is worth. const decidedThreadKeys = new Set(); // A layout the user has already shaped: an entry in either store, or a terminal @@ -35,10 +37,12 @@ function hasPanelState(threadKey: string): boolean { ); } -export function applyNewThreadPanelDefaults( - threadRef: ScopedThreadRef, - settings: NewThreadPanelDefaults, -): void { +export async function applyNewThreadPanelDefaults(threadRef: ScopedThreadRef): Promise { + // Client settings hydrate asynchronously and read as the defaults — both of + // these off — until that lands. The index route opens its draft inside that + // window, so reading the snapshot directly would skip the first chat of the + // session and never come back to it. + const settings = await getHydratedClientSettings(); if (!settings.newThreadOpenFilesPanel && !settings.newThreadOpenTerminal) return; // "New chat" hands back an unused draft rather than minting one whenever it // can, so the defaults must never re-force a layout: whatever that draft From 00583cff49407f3e01c9a6d715e95bd55a2dd572 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Tue, 11 Aug 2026 14:41:07 -0700 Subject: [PATCH 5/6] docs(user): document the new-chat panel layout settings Two settings now change how every new chat in a project opens, with nothing under docs/user describing them. --- docs/README.md | 1 + docs/user/new-chat-layout.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 docs/user/new-chat-layout.md diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..4661de4b8495 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Panel layout for new chats](./user/new-chat-layout.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/new-chat-layout.md b/docs/user/new-chat-layout.md new file mode 100644 index 000000000000..3cad18d14b7e --- /dev/null +++ b/docs/user/new-chat-layout.md @@ -0,0 +1,18 @@ +# Panel layout for new chats + +A new chat opens as a chat column on its own. If you always want the files panel or a terminal +next to it, T3 Code can open them for you on web and desktop. + +1. Open **Settings** and select **General**. +2. Turn on **Open files panel in new chats**, **Open terminal in new chats**, or both. + +Both are off by default. When one is on, it applies wherever a new chat in a project begins — the +new chat button, the command palette, a keyboard shortcut, checking out a pull request, or sending +a plan off to be implemented in its own chat. + +These settings only decide how a chat starts. Closing the files panel or the terminal applies to +that chat alone, and the next new chat still opens with the layout you asked for. Turning a setting +on or off leaves the chats you already have as they are. + +The preference is stored on the client you set it on, so each computer or browser you use T3 Code +from keeps its own layout for new chats. From 6d08b035f16316c697a4292533e9d0abc39cffd8 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Tue, 11 Aug 2026 16:09:04 -0700 Subject: [PATCH 6/6] fix(web): decide an all-closed new-chat layout once (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A draft first opened while both panel defaults were off returned before it was recorded in decidedThreadKeys, so enabling a default and reusing that same unused draft opened a panel in an already-on-screen chat — unlike a draft first opened with a default on, which is marked decided. Mark the decision before the all-closed early return so every initial layout is decided once per chat. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/newThreadPanelDefaults.test.ts | 13 +++++++++++++ apps/web/src/newThreadPanelDefaults.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/newThreadPanelDefaults.test.ts b/apps/web/src/newThreadPanelDefaults.test.ts index fa4889d71e97..3b181184eebd 100644 --- a/apps/web/src/newThreadPanelDefaults.test.ts +++ b/apps/web/src/newThreadPanelDefaults.test.ts @@ -169,6 +169,19 @@ describe("applyNewThreadPanelDefaults", () => { expect(rightPanelState().isOpen).toBe(false); }); + it("decides an all-closed layout once, so enabling a default later leaves the draft alone", async () => { + // First opened while both defaults are off: the chat is decided, all-closed. + useSettings(BOTH_OFF); + await applyNewThreadPanelDefaults(threadRef); + + // The user turns a default on; "New chat" hands back the same unused draft. The layout was + // already decided, so this must not open a panel in a chat that has been on screen. + useSettings(FILES_ONLY); + await applyNewThreadPanelDefaults(threadRef); + + expect(rightPanelState()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); + }); + it("does not re-default a chat whose layout the user emptied", async () => { useSettings(FILES_ONLY); await applyNewThreadPanelDefaults(threadRef); diff --git a/apps/web/src/newThreadPanelDefaults.ts b/apps/web/src/newThreadPanelDefaults.ts index 358ab79a6342..802cfa81b0c2 100644 --- a/apps/web/src/newThreadPanelDefaults.ts +++ b/apps/web/src/newThreadPanelDefaults.ts @@ -43,15 +43,18 @@ export async function applyNewThreadPanelDefaults(threadRef: ScopedThreadRef): P // window, so reading the snapshot directly would skip the first chat of the // session and never come back to it. const settings = await getHydratedClientSettings(); - if (!settings.newThreadOpenFilesPanel && !settings.newThreadOpenTerminal) return; // "New chat" hands back an unused draft rather than minting one whenever it // can, so the defaults must never re-force a layout: whatever that draft // already has wins, including a panel the user deliberately closed. Recording // the chat before acting on it keeps that true once the stores have forgotten // an emptied layout — the decision is made once per chat, not once per press. + // An all-closed decision counts too: a chat first opened while both defaults + // were off is decided all-closed, so enabling a default later does not open a + // panel in that already-shaped draft. const threadKey = scopedThreadKey(threadRef); if (decidedThreadKeys.has(threadKey)) return; decidedThreadKeys.add(threadKey); + if (!settings.newThreadOpenFilesPanel && !settings.newThreadOpenTerminal) return; if (hasPanelState(threadKey)) return; if (settings.newThreadOpenFilesPanel) { useRightPanelStore.getState().open(threadRef, "files");