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
20 changes: 11 additions & 9 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider, useTabs } from "@/context/tabs"
import { startupTab, TabsProvider, useTabs } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
Expand Down Expand Up @@ -144,20 +144,22 @@ function SessionProviders(props: ParentProps) {

function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
const tabs = useTabs()
const server = useServer()
const navigate = useNavigate()
const location = useLocation()

// On startup: as soon as persisted tabs are loaded (local disk, no HTTP calls),
// navigate to the last visited project's session list. This matches opencode's approach —
// use local/cached data to drive the first frame, let the server data fill in asynchronously.
// We navigate to /:dir/session (no specific session ID) so autoselecting can handle
// the final session selection independently without conflicting navigation.
// Restore the explicitly persisted active tab. A cross-server directory is never
// navigated until the target server has become active.
createEffect(() => {
if (!tabs.ready()) return
if (location.pathname !== "/") return
const first = tabs.store[0]
if (!first) return
navigate(`/${first.dirBase64}/session`, { replace: true })
const tab = startupTab(tabs.store, tabs.active.key, server.list)
if (!tab) return
if (server.key !== tab.server) {
server.setActive(tab.server)
return
}
navigate(`/${tab.dirBase64}/session`, { replace: true })
})

return (
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export interface TerminalProps extends ComponentProps<"div"> {
runtimeId?: string
onSubmit?: () => void
onStatusChange?: (status: TerminalStatus, error?: TerminalFailure) => void
/** When true (restored PTY), show the terminal immediately after xterm initialises
* instead of waiting for the WebSocket handshake. The WebSocket still connects in
* the background; input typed before it's ready is buffered. */
optimisticReady?: boolean
}

let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined
Expand Down Expand Up @@ -159,6 +163,7 @@ export const Terminal = (props: TerminalProps) => {
"runtimeId",
"onSubmit",
"onStatusChange",
"optimisticReady",
])
const id = local.pty.ptyId
let ws: WebSocket | undefined
Expand Down Expand Up @@ -435,6 +440,8 @@ export const Terminal = (props: TerminalProps) => {
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
// When optimisticReady is active the buffering handler below takes over.
if (local.optimisticReady) return
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
Expand All @@ -456,6 +463,28 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(t.cols, t.rows)
startResize()

// For restored PTYs: show the terminal surface immediately after xterm is
// initialised instead of waiting for the full WebSocket handshake (which can
// take 2-4 s). Input typed before the socket opens is buffered and flushed
// once the connection is established.
let inputBuffer = local.optimisticReady ? "" : undefined
if (local.optimisticReady) {
markReady()
// Intercept onData to buffer keystrokes until the WebSocket is open.
const onDataOpt = t.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
if (inputBuffer) {
ws.send(inputBuffer)
inputBuffer = undefined
}
ws.send(data)
} else {
inputBuffer = (inputBuffer ?? "") + data
}
})
cleanups.push(() => disposeIfDisposable(onDataOpt))
}

const once = { value: false }
const decoder = new TextDecoder()

Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const session = key()
const current = store.sessionView[session]
const bottom = next.bottomPanel
// Keep the project-level terminal flag in sync so that switching to a
// session that has never explicitly opened the panel doesn't close it.
// `bottomPanel` memo falls back to `store.terminal?.opened` for new
// sessions — this makes that fallback reflect the current user intent.
setTerminalOpened(bottom?.opened === true && bottom?.activeView === "terminal")
if (!current) {
setStore("sessionView", session, { scroll: {}, bottomPanel: bottom, rightPanelMode: next.rightPanelMode })
return
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/context/tabs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "./server"
import { startupTab, tabKey, type Tab } from "./tabs"

const primary = ServerConnection.Key.make("http://primary")
const secondary = ServerConnection.Key.make("http://secondary")

const servers = [
{ type: "http" as const, http: { url: "http://primary" } },
{ type: "http" as const, http: { url: "http://secondary" } },
]

const first: Tab = {
type: "session",
server: primary,
dirBase64: "L3ByaW1hcnk=",
sessionId: "first",
}
const lastActive: Tab = {
type: "session",
server: secondary,
dirBase64: "L3NlY29uZGFyeQ==",
sessionId: "last-active",
}

describe("startup tab recovery", () => {
test("restores the persisted active tab rather than the first tab", () => {
expect(startupTab([first, lastActive], tabKey(lastActive), servers)).toBe(lastActive)
})

test("falls back to the first stored tab when the active key is stale", () => {
expect(startupTab([first, lastActive], "stale", servers)).toBe(first)
})

test("does not restore a target whose server is unavailable", () => {
expect(startupTab([first, lastActive], tabKey(lastActive), [servers[0]])).toBeUndefined()
})
})
26 changes: 24 additions & 2 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export type Tab = SessionTab
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`

export function activeTab(tabs: Tab[], key?: string) {
return tabs.find((tab) => tabKey(tab) === key) ?? tabs[0]
}

export function startupTab(tabs: Tab[], activeKey: string | undefined, servers: ServerConnection.Any[]) {
const tab = activeTab(tabs, activeKey)
if (!tab) return
return servers.some((server) => ServerConnection.key(server) === tab.server) ? tab : undefined
}

export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
return tabs.some(
Expand Down Expand Up @@ -47,6 +57,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [active, setActive, _activePersist, activeReady] = persisted(
Persist.global("tabs.active"),
createStore({ key: undefined as string | undefined }),
)

const params = useParams()
const navigate = useNavigate()
Expand All @@ -61,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})

const navigateTab = (tab: Tab) => {
setActive("key", tabKey(tab))
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
Expand Down Expand Up @@ -96,13 +111,20 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
if (nextTab) navigateTab(nextTab)
else navigate("/")
else {
setActive("key", undefined)
navigate("/")
}
}).finally(() => closing.delete(key))
},
removeServer(key: ServerConnection.Key) {
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
if (active.key?.startsWith(`${key}\n`)) setActive("key", undefined)
if (server.key === key) navigate("/")
},
setActive(tab: Tab) {
navigateTab(tab)
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
Expand Down Expand Up @@ -145,6 +167,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}

return { ...actions, store, ready }
return { ...actions, active, ready: () => ready() && activeReady(), store }
},
})
61 changes: 58 additions & 3 deletions packages/app/src/context/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type LocalPTY = {
titleNumber: number
status: TerminalStatus
error?: TerminalFailure
/** True when restored from cross-project navigation cache; cleared on first ready. */
restored?: boolean
}

export type TerminalStore = {
Expand Down Expand Up @@ -313,6 +315,14 @@ function removeTerminalPersistence(
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
const sessions = new Set<{ dir: string; scope: ServerScopeValue; value: TerminalSession }>()

/** Per-directory PTY snapshot preserved across project switches so PTYs survive navigation. */
interface TerminalPtySnapshot {
ptys: Array<Pick<LocalPTY, "id" | "ptyId" | "title" | "titleNumber">>
root: PaneNode
focusedPaneId: string
}
const directoryTerminalCache = new Map<string, { bottom: TerminalPtySnapshot | null; side: TerminalPtySnapshot | null }>()

export function clearWorkspaceTerminals(
dir: string,
sessionIDs?: string[],
Expand Down Expand Up @@ -535,6 +545,37 @@ function createWorkspaceTerminalSession(
resetRuntime() {
reset()
},
snapshot(): TerminalPtySnapshot | null {
if (store.all.length === 0) return null
return {
ptys: store.all.map((pty) => ({
id: pty.id,
ptyId: pty.ptyId,
title: pty.title,
titleNumber: pty.titleNumber,
})),
root: root(),
focusedPaneId: focusedPaneId(),
}
},
restore(snapshot: TerminalPtySnapshot) {
batch(() => {
setStore(
"all",
snapshot.ptys.map((p) => ({
id: p.id,
ptyId: p.ptyId,
title: p.title,
titleNumber: p.titleNumber,
status: "connecting" as TerminalStatus,
error: undefined,
restored: true,
})),
)
setRootSignal(clonePaneTree(snapshot.root))
setFocusedPaneId(snapshot.focusedPaneId)
})
},
clear() {
const ptyIds = store.all.map((pty) => pty.ptyId)
reset()
Expand Down Expand Up @@ -633,7 +674,7 @@ function createWorkspaceTerminalSession(
setStatus(id: string, ptyId: string, status: TerminalStatus, error?: TerminalFailure) {
const index = store.all.findIndex((pty) => pty.id === id && pty.ptyId === ptyId)
if (index === -1) return
setStore("all", index, { status, error: status === "ready" ? undefined : error })
setStore("all", index, { status, error: status === "ready" ? undefined : error, ...(status === "ready" ? { restored: false } : {}) })
},
update(input: Partial<LocalPTY> & { id: string }) {
if (input.title === undefined) return
Expand Down Expand Up @@ -772,6 +813,16 @@ const { use: useTerminalDual, provider: TerminalProvider } = createSimpleContext
bottomSession = createWorkspaceTerminalSession(sdk, runtime)
sideSession = createWorkspaceTerminalSession(sdk, runtime)

// Restore previously-saved PTYs when returning to a directory.
// The cached snapshot was written by onCleanup — PTYs stayed alive on the
// server and just need to be re-registered in local reactive state.
const cached = directoryTerminalCache.get(sdk.directory)
if (cached) {
directoryTerminalCache.delete(sdk.directory)
if (cached.bottom) bottomSession.restore(cached.bottom)
if (cached.side) sideSession.restore(cached.side)
}

const bottomReg = { dir: sdk.directory, scope, value: bottomSession }
const sideReg = { dir: sdk.directory, scope, value: sideSession }
sessions.add(bottomReg)
Expand All @@ -786,10 +837,14 @@ const { use: useTerminalDual, provider: TerminalProvider } = createSimpleContext
onCleanup(() => clearInterval(timer))
})
onCleanup(() => {
// Save PTY state so terminals survive project navigation. Do NOT call
// clear() here — that would DELETE PTYs on the server. The PTYs keep
// running and are reconnected when the user returns to this directory.
const bottomSnap = bottomSession?.snapshot() ?? null
const sideSnap = sideSession?.snapshot() ?? null
directoryTerminalCache.set(sdk.directory, { bottom: bottomSnap, side: sideSnap })
sessions.delete(bottomReg)
sessions.delete(sideReg)
bottomSession?.clear()
sideSession?.clear()
})

return { bottom: bottomSession, side: sideSession }
Expand Down
7 changes: 2 additions & 5 deletions packages/app/src/pages/session/session-side-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { IdeFileEditor } from "@/pages/session/ide-file-editor"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SidePanelSubagents } from "@/pages/session/side-panel-subagents"
import { isSubagentInterrupted } from "@/pages/session/subagent-state"
import { SidePanelBrowser } from "@/pages/session/side-panel-browser"
import { SidePanelWorktree } from "@/pages/session/side-panel-worktree"
import { SidePanelDebug } from "@/pages/session/side-panel-debug"
Expand Down Expand Up @@ -135,11 +136,7 @@ export function SessionSidePanel(props: {
() => subagentChildren().filter((s) => sync.data.session_working(s.id)).length,
)
const interruptedSubagentCount = createMemo(
() =>
subagentChildren().filter((s) => {
const sub = (s.metadata?.["deepagent"] as { subagent?: { interrupted?: boolean } } | undefined)?.subagent
return sub?.interrupted === true
}).length,
() => subagentChildren().filter(isSubagentInterrupted).length,
)
const subagentAttentionCount = createMemo(
() => runningSubagentCount() + interruptedSubagentCount(),
Expand Down
7 changes: 3 additions & 4 deletions packages/app/src/pages/session/side-panel-subagents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { fetchCapabilities } from "@/components/deepagent/panel-goal.api"
import { OversightDashboard } from "@/components/deepagent/oversight-dashboard"
import { isSubagentInterrupted } from "./subagent-state"

// Phase 2 (§3): SidePanelSubagents is now the single "子Agent监督" entry for the right rail.
// It holds a `selectedSessionID` to track which subagent is being inspected; that selection
Expand All @@ -23,6 +24,7 @@ import { OversightDashboard } from "@/components/deepagent/oversight-dashboard"
// (scope+route SessionStateKey): that never equals a child's parentID, so the list was always
// empty. Resolving it internally here (like SidePanelIM / SidePanelDebug do) keeps the contract
// simple and immune to that mismatch.

export const SidePanelSubagents: Component<{ onClose: () => void }> = (props) => {
const sync = useSync()
const language = useLanguage()
Expand Down Expand Up @@ -56,10 +58,7 @@ export const SidePanelSubagents: Component<{ onClose: () => void }> = (props) =>
const sub = (child.metadata?.["deepagent"] as { subagent?: { finished?: boolean } } | undefined)?.subagent
return sub?.finished === true
}
const isInterrupted = (child: { metadata?: Record<string, unknown> }): boolean => {
const sub = (child.metadata?.["deepagent"] as { subagent?: { interrupted?: boolean } } | undefined)?.subagent
return sub?.interrupted === true
}
const isInterrupted = isSubagentInterrupted
const statusOf = (
child: { id: string; metadata?: Record<string, unknown> },
): "running" | "finished" | "interrupted" | "idle" => {
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/pages/session/subagent-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test"
import { isSubagentInterrupted } from "./subagent-state"

describe("isSubagentInterrupted", () => {
test("recognizes durable state and legacy boolean interruption markers", () => {
expect(isSubagentInterrupted({ deepagent: { subagent: { state: "interrupted" } } })).toBe(true)
expect(isSubagentInterrupted({ deepagent: { subagent: { interrupted: true } } })).toBe(true)
expect(isSubagentInterrupted({ deepagent: { subagent: { state: "finished" } } })).toBe(false)
})
})
10 changes: 10 additions & 0 deletions packages/app/src/pages/session/subagent-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type SubagentMetadata = {
state?: string
interrupted?: boolean
}

/** Supports durable state markers and legacy boolean interruption markers. */
export const isSubagentInterrupted = (metadata?: Record<string, unknown>) => {
const subagent = (metadata?.["deepagent"] as { subagent?: SubagentMetadata } | undefined)?.subagent
return subagent?.state === "interrupted" || subagent?.interrupted === true
}
1 change: 1 addition & 0 deletions packages/app/src/pages/session/terminal-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ function TerminalSessionView(props: { pty: LocalPTY; focused: boolean }) {
autoFocus={props.focused}
runtimeId={terminal.runtimeId()}
onStatusChange={(next, error) => terminal.setStatus(props.pty.id, ptyId, next, error)}
optimisticReady={props.pty.restored}
/>
)}
</Show>
Expand Down
Loading
Loading