Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/renderer/src/app-state/settings/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export function coerceSettings(value: unknown): Settings {
// non-strings so a corrupt localStorage blob cannot break settings boot.
dictationShortcut: coerceHotkeyBinding(parsed.dictationShortcut),
aggressiveDebugPersistence: parsed.aggressiveDebugPersistence === true,
// Strict `=== true` so missing OR malformed values default to off —
// matches the "off by default, opt in" semantics promised in the
// setting's docstring. Anything looser (e.g. `!== false`) would
// flip the default to ON for fresh installs.
dispatchProjectTerminal: parsed.dispatchProjectTerminal === true,
// WHY membership check via WORKSPACE_MODES rather than a literal
// === 'dispatch': keeps the source of truth in one array so adding
// a new mode label later (if ever) only requires editing types.ts.
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/src/app-state/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const ACCENTS: AccentMeta[] = [
// WHY a separate type: this is the user-preference choice (a label)
// for which mode the app should boot into on a *fresh install*. It is
// deliberately NOT the same shape as DispatchModeState — the workspace
// mode at runtime is null for grid and a {scope, terminalVisible}
// mode at runtime is null for grid and a {scope, focusedSessionId?}
// object for dispatch, but the *preference* only needs to encode "which
// of the two should we start in". Keeping this as a flat string union
// keeps localStorage payload stable, makes coerceSettings trivial, and
Expand Down Expand Up @@ -122,6 +122,20 @@ export type Settings = {
* DOM, semantic, and feed-debug snapshots, so they are interval-
* based rather than emitted on every render. */
aggressiveDebugPersistence: boolean
/** When true, Dispatch Mode mounts a project terminal pane beside the
* agent list. The terminal is auto-spawned on first entry to Dispatch
* and lives as a normal leaf in the tile tree (so tmux recovery and
* IPC routing keep working unchanged).
*
* Off by default. The previous design kept a per-session
* `dispatchMode.terminalVisible` flag in workspace state, which made
* the "I turned it off but it came back" symptom hard to reason
* about: fresh workspaces, new tabs, and any code path that re-
* entered dispatch defaulted the flag to ON. Moving the gate to a
* global setting collapses the toggle surface to one place the user
* controls and removes the "terminal always mounted even when turned
* off" failure mode. */
dispatchProjectTerminal: boolean
}

export const DEFAULT_SETTINGS: Settings = {
Expand All @@ -138,4 +152,5 @@ export const DEFAULT_SETTINGS: Settings = {
dictationShortcut: 'Fn',
aggressiveDebugPersistence: false,
defaultWorkspaceMode: 'grid',
dispatchProjectTerminal: false,
}
2 changes: 0 additions & 2 deletions src/renderer/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,6 @@ export default function App() {
)
}
exitDispatchMode={workspace.exitDispatchMode}
toggleDispatchTerminal={workspace.toggleDispatchTerminal}
openDispatchAttach={openDispatchAttach}
onTileTabsRequest={onTileTabsRequest}
onReorderTabsRequest={openReorderTabs}
Expand All @@ -470,7 +469,6 @@ export default function App() {
performancePanelOpen={performancePanelOpen}
dispatchModeEnabled={workspace.dispatchMode !== null}
globalDispatchEnabled={workspace.dispatchMode?.scope === 'global'}
dispatchTerminalVisible={workspace.dispatchMode?.terminalVisible !== false}
setDangerousAgentsEnabled={enabled => setSettings({ dangerousAgentsEnabled: enabled })}
setAggressiveDebugPersistence={enabled =>
setSettings({ aggressiveDebugPersistence: enabled })}
Expand Down
2 changes: 0 additions & 2 deletions src/renderer/src/features/command-palette/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export type CommandContext = {
enterDispatchMode: () => Promise<void> | void
enterGlobalDispatch: () => Promise<void> | void
exitDispatchMode: () => void
toggleDispatchTerminal: () => Promise<void> | void
/** Open the placement overlay in "attach detached session to grid"
* mode for the given sessionId. The session must exist in
* workspace.state.detachedSessions; the command's `when` guard is
Expand Down Expand Up @@ -60,7 +59,6 @@ export type CommandContext = {
performancePanelOpen: boolean
dispatchModeEnabled: boolean
globalDispatchEnabled: boolean
dispatchTerminalVisible: boolean
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ type Props = {
enterDispatchMode: () => Promise<void> | void
enterGlobalDispatch: () => Promise<void> | void
exitDispatchMode: () => void
toggleDispatchTerminal: () => Promise<void> | void
openDispatchAttach: (sessionId: string) => void
toggleCustomRendering: () => void
toggleStatusMode: () => void
Expand All @@ -96,7 +95,6 @@ type Props = {
performancePanelOpen: boolean
dispatchModeEnabled: boolean
globalDispatchEnabled: boolean
dispatchTerminalVisible: boolean
setDangerousAgentsEnabled: (enabled: boolean) => void
setAggressiveDebugPersistence: (enabled: boolean) => void
}
Expand Down Expand Up @@ -134,7 +132,6 @@ export function CommandPalette({
enterDispatchMode,
enterGlobalDispatch,
exitDispatchMode,
toggleDispatchTerminal,
openDispatchAttach,
toggleCustomRendering,
toggleStatusMode,
Expand All @@ -153,7 +150,6 @@ export function CommandPalette({
performancePanelOpen,
dispatchModeEnabled,
globalDispatchEnabled,
dispatchTerminalVisible,
setDangerousAgentsEnabled,
setAggressiveDebugPersistence,
}: Props) {
Expand Down Expand Up @@ -281,7 +277,6 @@ export function CommandPalette({
enterDispatchMode,
enterGlobalDispatch,
exitDispatchMode,
toggleDispatchTerminal,
openDispatchAttach,
toggleCustomRendering,
toggleStatusMode,
Expand Down Expand Up @@ -310,7 +305,6 @@ export function CommandPalette({
performancePanelOpen,
dispatchModeEnabled,
globalDispatchEnabled,
dispatchTerminalVisible,
},
}),
[
Expand All @@ -334,7 +328,6 @@ export function CommandPalette({
enterDispatchMode,
enterGlobalDispatch,
exitDispatchMode,
toggleDispatchTerminal,
toggleCustomRendering,
toggleStatusMode,
toggleWorktreeBadges,
Expand All @@ -360,7 +353,6 @@ export function CommandPalette({
performancePanelOpen,
dispatchModeEnabled,
globalDispatchEnabled,
dispatchTerminalVisible,
],
)

Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/features/settings/lib/settingsRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,25 @@ export function getSettingsRegistry(): SettingDefinition[] {
onToggle: (ctx, value) => ctx.onChange({ showWorktreeBadges: value }),
},
},
{
// Replaces the old "Dispatch Terminal" command-palette toggle. The
// command sat on a per-session `dispatchMode.terminalVisible` flag
// that re-defaulted to ON every time dispatch was re-entered, which
// produced the "I turned it off but it's back" failure mode. The
// settings entry is the single source of truth: off → never mount,
// on → mount and live as a normal tile-tree leaf.
id: 'dispatch-project-terminal',
category: 'workspace',
title: 'Attach Project Terminal to Dispatch',
description:
'When on, Dispatch Mode mounts a project terminal beside the agent list. Off by default.',
keywords: ['dispatch', 'terminal', 'project', 'attach', 'shell', 'pane'],
control: {
type: 'toggle',
getValue: settings => settings.dispatchProjectTerminal,
onToggle: (ctx, value) => ctx.onChange({ dispatchProjectTerminal: value }),
},
},
{
id: 'aggressive-debug-persistence',
category: 'experimental',
Expand Down
21 changes: 7 additions & 14 deletions src/renderer/src/features/workspace/commands/layoutCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,13 @@ export const layoutCommands: CommandDef[] = [
when: ({ flags }) => flags.dispatchModeEnabled,
run: ({ ui }) => ui.exitDispatchMode(),
},
{
id: 'toggle-dispatch-terminal',
title: 'Dispatch Terminal',
description: '**What it does:** Shows or hides the project terminal inside **Dispatch**.\n\n**Use when:** You want shell access beside the selected agent.\n\n**Notes:** The terminal is tied to the active project tab.',
keywords: ['project terminal', 'right terminal'],
when: ({ flags }) => flags.dispatchModeEnabled,
getState: ({ flags }) => ({
label: flags.dispatchTerminalVisible ? 'On' : 'Off',
tone: flags.dispatchTerminalVisible ? 'accent' : 'neutral',
}),
run: async ({ ui }) => {
await ui.toggleDispatchTerminal()
},
},
// REMOVED: 'toggle-dispatch-terminal' command. The dispatch project
// terminal is now controlled by `settings.dispatchProjectTerminal`
// (default OFF) rather than a per-session command-palette toggle. The
// old command sat on top of an ephemeral `dispatchMode.terminalVisible`
// flag that re-defaulted to ON every time dispatch was re-entered,
// producing the "I turned it off but it came back" failure mode.
// Search settings → "Attach Project Terminal to Dispatch" to toggle.
{
id: 'normalize-layout',
title: 'Normalize Layout',
Expand Down
28 changes: 25 additions & 3 deletions src/renderer/src/features/workspace/commands/paneCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,27 @@ export const paneCommands: CommandDef[] = [
},
},
{
// Available in BOTH grid and Dispatch modes. The original gate was
// `dispatchCommandTabId`, which returned null whenever the workspace
// was not in Dispatch — that was the wrong shape for this command.
// Detached agents can outlive a Dispatch session (you can leave
// Dispatch with agents still parked), and the natural recovery flow
// is "from the regular grid, bring my parked agents back into this
// tab." Forcing the user to flip into Dispatch first was friction
// with no upside. In Dispatch we still delegate to the dispatch-
// aware resolver so global Dispatch can target the focused row's
// tab (which may differ from `activeTabId`).
id: 'attach-all-detached-for-tab',
title: 'Attach All Dispatch Agents For Tab',
description: '**What it does:** Moves all detached **Dispatch** agents for a tab into the grid.\n\n**Use when:** You want to bring a whole tab’s background agents into view.\n\n**Notes:** Preserves the existing grid and adds the agents beside it.',
description: '**What it does:** Moves all detached **Dispatch** agents for a tab into the grid.\n\n**Use when:** You want to bring a whole tab’s background agents into view.\n\n**Notes:** Preserves the existing grid and adds the agents beside it. Works in both Grid and Dispatch modes.',
keywords: ['attach', 'all', 'detached', 'dispatch', 'grid', 'tab', 'pin'],
when: ({ workspace }) => {
const tabId = dispatchCommandTabId(workspace)
const tabId = attachAllCommandTabId(workspace)
if (!tabId) return false
return detachedDispatchSessionIdsForTab(workspace.state, tabId).length > 0
},
run: ({ workspace }) => {
const tabId = dispatchCommandTabId(workspace)
const tabId = attachAllCommandTabId(workspace)
if (!tabId) return
workspace.attachAllDetachedForTab(tabId)
},
Expand Down Expand Up @@ -253,3 +263,15 @@ function dispatchCommandTabId(
)
return row?.tabId ?? workspace.state.activeTabId ?? null
}

// Resolver for "attach all dispatch agents for tab" that works in BOTH
// modes. In Dispatch we delegate to `dispatchCommandTabId` so global
// Dispatch can target the focused row's tab (potentially != activeTabId).
// Outside Dispatch we use the active tab — there is no dispatch focus
// to consult and the user's only reasonable target is "this tab."
function attachAllCommandTabId(
workspace: CommandContext['workspace'],
): string | null {
if (workspace.dispatchMode) return dispatchCommandTabId(workspace)
return workspace.state.activeTabId || null
}
15 changes: 14 additions & 1 deletion src/renderer/src/workspace/dispatch/DispatchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ export function DispatchLayout({
? workspace.state.tabs.find(tab => tab.id === activeRow.tabId) ?? null
: workspace.activeTab
const terminalSessionId = findTerminalSessionInTab(activeTab, workspace.state)
const terminalVisible = workspace.state.dispatchMode?.terminalVisible !== false
// Source of truth for whether the project terminal mounts is now the
// global setting, not the ephemeral `dispatchMode.terminalVisible` we
// used to keep on workspace state. The setting defaults to OFF —
// matches the user's "off by default, opt in" intent and removes the
// "I turned it off but it came back" failure mode that the per-session
// flag suffered from. Toggle is in Settings → Workspace.
const terminalVisible = useAppStore(state => state.settings.dispatchProjectTerminal)

useEffect(() => {
if (!activeRow) return
Expand All @@ -76,6 +82,13 @@ export function DispatchLayout({
])

useEffect(() => {
// Spawn the terminal lazily when the user opts in. This is the ONLY
// entry point that calls ensureDispatchTerminal — the dispatch
// actions (enter/setScope) deliberately no longer do, so flipping
// the setting OFF and re-entering Dispatch will NOT silently spawn
// a fresh terminal in the background. The existing terminal in a
// tab that was spawned earlier stays as a tile-tree leaf; if the
// user wants to remove it, they close it like any other pane.
if (!terminalVisible || !activeTab) return
void workspace.ensureDispatchTerminal(activeTab.id)
}, [activeTab?.id, terminalVisible, workspace.ensureDispatchTerminal])
Expand Down
38 changes: 14 additions & 24 deletions src/renderer/src/workspace/hook/actions/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function useDispatchActions(
enterDispatchMode: (scope?: DispatchModeState['scope']) => Promise<void>
exitDispatchMode: () => void
setDispatchScope: (scope: DispatchModeState['scope']) => Promise<void>
toggleDispatchTerminal: () => Promise<void>
ensureDispatchTerminal: (tabId?: TabId) => Promise<SessionId | null>
focusDispatchSession: (tabId: TabId, sessionId: SessionId) => void
} {
Expand Down Expand Up @@ -114,14 +113,20 @@ export function useDispatchActions(
...prev,
dispatchMode: {
scope,
terminalVisible: prev.dispatchMode?.terminalVisible ?? true,
focusedSessionId: prev.dispatchMode?.focusedSessionId,
},
}))
setTileTabs(null)
await ensureDispatchTerminal()
// Terminal mount is now gated by `settings.dispatchProjectTerminal`,
// which lives outside this action's reach (settings live in the
// settings store, dispatch state lives in workspace state).
// DispatchLayout's useEffect reads the setting and calls
// ensureDispatchTerminal itself when appropriate — meaning we
// deliberately do NOT unconditionally fire it here anymore. Doing so
// would spawn a terminal even with the setting OFF, which is the
// exact bug shape we're fixing.
},
[closeNewAgentPlacement, ensureDispatchTerminal, setState, setTileTabs, state.dispatchMode?.scope],
[closeNewAgentPlacement, setState, setTileTabs, state.dispatchMode?.scope],
)

const exitDispatchMode = useCallback(() => {
Expand All @@ -138,31 +143,17 @@ export function useDispatchActions(
...prev,
dispatchMode: {
scope,
terminalVisible: prev.dispatchMode?.terminalVisible ?? true,
focusedSessionId: prev.dispatchMode?.focusedSessionId,
},
}))
await ensureDispatchTerminal()
// Same rationale as enterDispatchMode: terminal mount is now the
// DispatchLayout effect's responsibility, gated by the global
// setting. Re-entering with a different scope must NOT spawn a
// terminal behind the setting's back.
},
[closeNewAgentPlacement, ensureDispatchTerminal, setState],
[closeNewAgentPlacement, setState],
)

const toggleDispatchTerminal = useCallback(async () => {
let nextVisible = true
setState(prev => {
const current = prev.dispatchMode ?? { scope: 'project' as const, terminalVisible: true }
nextVisible = !current.terminalVisible
return {
...prev,
dispatchMode: {
...current,
terminalVisible: nextVisible,
},
}
})
if (nextVisible) await ensureDispatchTerminal()
}, [ensureDispatchTerminal, setState])

const focusDispatchSession = useCallback(
(tabId: TabId, sessionId: SessionId) => {
setState(prev => {
Expand Down Expand Up @@ -190,7 +181,6 @@ export function useDispatchActions(
enterDispatchMode,
exitDispatchMode,
setDispatchScope,
toggleDispatchTerminal,
ensureDispatchTerminal,
focusDispatchSession,
}
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/workspace/hook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,6 @@ export function useWorkspace(
enterDispatchMode: dispatchActions.enterDispatchMode,
exitDispatchMode: dispatchActions.exitDispatchMode,
setDispatchScope: dispatchActions.setDispatchScope,
toggleDispatchTerminal: dispatchActions.toggleDispatchTerminal,
ensureDispatchTerminal: dispatchActions.ensureDispatchTerminal,
focusDispatchSession: dispatchActions.focusDispatchSession,
}
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ export type DetachedSessionRecord = {

export type DispatchModeState = {
scope: 'project' | 'global'
terminalVisible: boolean
/**
* Dispatch Mode selection is separate from grid focus. Reusing
* Tab.focusedSessionId for detached rows would violate the tile-tree
Expand All @@ -167,6 +166,12 @@ export type DispatchModeState = {
* grid in an impossible focus state.
*/
focusedSessionId?: SessionId
// HISTORICAL: a `terminalVisible: boolean` flag used to live here. It
// was replaced by the global `settings.dispatchProjectTerminal` toggle
// because the per-session flag re-defaulted to ON every time dispatch
// was re-entered, producing the "I turned it off but it's back" bug.
// Old workspace.json files may still carry the field; it is now ignored
// (TypeScript drops unknown properties at runtime on shape coercion).
}

export type WorkspaceState = {
Expand Down