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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 32a8e309beb9..0cb0b973bf82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -186,6 +186,7 @@ import { } 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"; @@ -1885,6 +1886,12 @@ 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) => { + void applyNewThreadPanelDefaults(scopeThreadRef(activeProjectRef.environmentId, threadId)); + }; const logicalProjectKey = deriveLogicalProjectKeyFromSettings( activeProject, projectGroupingSettings, @@ -1901,6 +1908,7 @@ function ChatViewContent(props: ChatViewProps) { ...input, }, ); + applyPanelDefaults(storedDraftSession.threadId); if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ to: "/draft/$draftId", @@ -1924,6 +1932,7 @@ function ChatViewContent(props: ChatViewProps) { interactionMode: activeDraftSession.interactionMode, ...input, }); + applyPanelDefaults(activeDraftSession.threadId); return activeDraftSession.threadId; } @@ -1936,6 +1945,7 @@ function ChatViewContent(props: ChatViewProps) { interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); + applyPanelDefaults(nextThreadId); await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(nextDraftId), @@ -5745,6 +5755,9 @@ 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. + void applyNewThreadPanelDefaults(scopeThreadRef(activeThread.environmentId, nextThreadId)); const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e4cfbe9ac033..42e1df052516 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -514,6 +514,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"] : []), @@ -532,6 +538,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.newThreadOpenFilesPanel, + settings.newThreadOpenTerminal, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, @@ -636,6 +644,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, @@ -2122,6 +2132,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" + /> + } + /> + { + 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 new file mode 100644 index 000000000000..3b181184eebd --- /dev/null +++ b/apps/web/src/newThreadPanelDefaults.test.ts @@ -0,0 +1,198 @@ +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"; +import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; + +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(); + +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); +const terminalState = () => + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ); + +beforeEach(() => { + threadRef = nextThreadRef(); + useRightPanelStore.setState({ byThreadKey: {} }); + useTerminalUiStateStore.persist.clearStorage(); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, + }); +}); + +describe("applyNewThreadPanelDefaults", () => { + 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", async () => { + useSettings(FILES_ONLY); + + await applyNewThreadPanelDefaults(threadRef); + + 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", 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", 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", async () => { + useSettings(BOTH_ON); + useRightPanelStore.getState().open(threadRef, "diff"); + useRightPanelStore.getState().close(threadRef); + + await applyNewThreadPanelDefaults(threadRef); + + 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", async () => { + useSettings(BOTH_ON); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); + + 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", 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 + // the store drops; the suppressed id is all that records the close. + expect( + scopedThreadKey(threadRef) in useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + ).toBe(false); + + await applyNewThreadPanelDefaults(threadRef); + + expect(terminalState().terminalOpen).toBe(false); + 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); + 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. + 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 new file mode 100644 index 000000000000..802cfa81b0c2 --- /dev/null +++ b/apps/web/src/newThreadPanelDefaults.ts @@ -0,0 +1,68 @@ +/** + * 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 { getHydratedClientSettings } from "./hooks/useSettings"; +import { useRightPanelStore } from "./rightPanelStore"; +import { useTerminalUiStateStore } from "./terminalUiStateStore"; + +// 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 +// 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 terminalUiState.terminalUiStateByThreadKey || + threadKey in terminalUiState.suppressedTerminalIdsByThreadKey + ); +} + +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(); + // "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"); + } + 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/docs/README.md b/docs/README.md index 30653e7d5035..c0f04f879d59 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) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.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. 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)),