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
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deepagent-code/app",
"version": "1.4.2",
"version": "1.4.3",
"description": "",
"type": "module",
"exports": {
Expand Down
23 changes: 21 additions & 2 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Font } from "@deepagent-code/ui/font"
import { Splash } from "@deepagent-code/ui/logo"
import { ThemeProvider } from "@deepagent-code/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useLocation, useNavigate } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
Expand Down 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 } 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 @@ -143,6 +143,25 @@ function SessionProviders(props: ParentProps) {
}

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

// 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 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 (
<AppShellProviders>
{/*<Suspense fallback={<Loading />}>*/}
Expand Down
46 changes: 37 additions & 9 deletions packages/app/src/components/deepagent/oversight-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,16 @@ function MetricCard(props: { label: string; value: string; tone?: "ok" | "warn"
)
}

export const OversightDashboard: Component = () => {
// Phase 2: OversightDashboard now accepts a selected subagent session ID so takeover/rollback
// automatically target the chosen session, and trace nodes can reverse-select a subagent.
export type OversightDashboardProps = {
/** When provided, takeover and rollback default to this session ID. */
selectedSessionID?: string
/** Called when a trace node with a sessionID is clicked so the caller can select that subagent. */
onSessionSelect?: (sessionID: string) => void
}

export const OversightDashboard: Component<OversightDashboardProps> = (props) => {
const sdk = useSDK()
const language = useLanguage()
const client = () => sdk.client as unknown as OversightClient
Expand Down Expand Up @@ -118,7 +127,9 @@ export const OversightDashboard: Component = () => {
if (!reason) return
setTakeoverBusy(true)
setTakeoverNote(null)
const result = await recordHumanTakeover(client(), { reason })
// Phase 2: automatically pass the selected session ID when present.
const sessionID = props.selectedSessionID
const result = await recordHumanTakeover(client(), { reason, ...(sessionID ? { sessionID } : {}) })
setTakeoverBusy(false)
if (result.ok) {
setTakeoverReason("")
Expand All @@ -133,21 +144,25 @@ export const OversightDashboard: Component = () => {
}

// ── §D2 rollback (P4.4) ─────────────────────────────────────────────────────────
const [rollbackSession, setRollbackSession] = createSignal("")
// Phase 2: rollback input is pre-seeded with the selected session ID. Users may override it.
const [rollbackReason, setRollbackReason] = createSignal("")
const [rollbackBusy, setRollbackBusy] = createSignal(false)
const [rollbackNote, setRollbackNote] = createSignal<string | null>(null)

// The effective session ID for rollback: the input field overrides; falls back to selectedSessionID.
const [rollbackSessionOverride, setRollbackSessionOverride] = createSignal("")
const effectiveRollbackSession = () => rollbackSessionOverride() || props.selectedSessionID || ""

const submitRollback = async () => {
const sessionID = rollbackSession().trim()
const sessionID = effectiveRollbackSession().trim()
if (!sessionID) return
const reason = rollbackReason().trim()
setRollbackBusy(true)
setRollbackNote(null)
const result = await recordRollback(client(), { sessionID, ...(reason ? { reason } : {}) })
setRollbackBusy(false)
if (result.ok) {
setRollbackSession("")
setRollbackSessionOverride("")
setRollbackReason("")
setRollbackNote(
result.record?.outcome === "noop"
Expand Down Expand Up @@ -328,6 +343,17 @@ export const OversightDashboard: Component = () => {
</div>
</Show>
<div class="text-11-regular text-text-weaker">{fmtTime(node.createdAt)}</div>
{/* Phase 2: if this node is tied to a specific subagent session, offer
a reverse-select link so the user can jump to that subagent row. */}
<Show when={node.sessionID && props.onSessionSelect}>
<button
type="button"
class="mt-0.5 text-11-regular text-text-link hover:underline"
onClick={() => props.onSessionSelect!(node.sessionID!)}
>
{language.t("oversight.trace.selectAgent")}
</button>
</Show>
</div>
</div>
)}
Expand Down Expand Up @@ -372,11 +398,13 @@ export const OversightDashboard: Component = () => {
<section>
<h3 class="mb-1 text-13-medium text-text-strong">{language.t("oversight.rollback.title")}</h3>
<p class="mb-2 text-11-regular text-text-weak">{language.t("oversight.rollback.description")}</p>
{/* Phase 2: when a subagent is selected the placeholder is replaced by the session ID.
Typing in this field overrides the pre-selected session. */}
<input
class="w-full rounded-md border border-border-weak-base bg-surface-base px-2 py-1.5 text-12-regular text-text-strong outline-none focus:ring-2 focus:ring-accent-base font-mono"
placeholder={language.t("oversight.rollback.sessionPlaceholder")}
value={rollbackSession()}
onInput={(e) => setRollbackSession(e.currentTarget.value)}
placeholder={props.selectedSessionID || language.t("oversight.rollback.sessionPlaceholder")}
value={rollbackSessionOverride()}
onInput={(e) => setRollbackSessionOverride(e.currentTarget.value)}
/>
<textarea
class="mt-2 w-full rounded-md border border-border-weak-base bg-surface-base px-2 py-1.5 text-12-regular text-text-strong outline-none resize-none focus:ring-2 focus:ring-accent-base"
Expand All @@ -391,7 +419,7 @@ export const OversightDashboard: Component = () => {
size="small"
icon="reset"
onClick={submitRollback}
disabled={rollbackBusy() || !rollbackSession().trim()}
disabled={rollbackBusy() || !effectiveRollbackSession().trim()}
>
<Show when={rollbackBusy()}>
<Spinner />
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/components/deepagent/oversight.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export type OversightTraceNode = {
source: string
causationID?: string
createdAt: number
// Phase 2: trace nodes may carry the session that emitted them so the UI can reverse-select
// the subagent row when the user clicks a trace entry.
sessionID?: string
}
export type OversightTrace = { nodes: OversightTraceNode[] }

Expand Down Expand Up @@ -148,7 +151,7 @@ export type HumanTakeoverRecord = {
*/
export const recordHumanTakeover = async (
client: OversightClient,
input: { reason: string; scope?: string },
input: { reason: string; scope?: string; sessionID?: string },
): Promise<{ ok: true; record?: HumanTakeoverRecord } | { ok: false; unsupported: boolean; error: string }> => {
try {
const response = await client.client.request<HumanTakeoverRecord>({
Expand Down
23 changes: 11 additions & 12 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { DOCK_PANEL_IDS, useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { useTerminalHosts } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { PANEL_VIEW_META } from "@/pages/session/panel-view-registry"
Expand Down Expand Up @@ -144,7 +144,7 @@ export function SessionHeader() {
const platform = usePlatform()
const language = useLanguage()
const sync = useSync()
const terminal = useTerminal()
const terminalHosts = useTerminalHosts()
const { params, view } = useSessionLayout()

const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
Expand Down Expand Up @@ -208,19 +208,18 @@ export function SessionHeader() {
]
})

const terminalOpen = createMemo(() => {
const panel = view().panel
return panel.location("terminal") === "bottom"
? panel.bottom.opened() && panel.bottom.activeView() === "terminal"
: view().rightPanel.mode() === "terminal"
})
// Phase 3: terminal toggle button in the header controls the **bottom** terminal only.
const terminalOpen = createMemo(() =>
view().panel.bottom.opened() && view().panel.bottom.activeView() === "terminal",
)

const toggleTerminal = () => {
view().panel.toggle("terminal")
const panel = view().panel
if (panel.location("terminal") === "side" && view().rightPanel.mode() !== "terminal") return
const id = terminal.active()
if (id) focusTerminalById(id)
// Focus the bottom terminal's active pane after opening.
if (terminalOpen()) {
const id = terminalHosts.bottom.active()
if (id) focusTerminalById(id)
}
}

const bottomPanelOpen = createMemo(() => view().panel.bottom.opened())
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
28 changes: 23 additions & 5 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ type SessionView = {
// U3/U4/U7: added "worktree" (isolated worktree diff/merge), "subagents" (child-session list),
// "browser" (isolated WebContentsView).
// T3.2: the "menu" mode is gone — an always-on icon rail replaced the full-panel menu list.
// Phase 2: "oversight" removed as standalone panel; kept in union only for backward-compat
// migration — any stored "oversight" is silently mapped to "subagents" at read time.
rightPanelMode?:
| "review"
| "files"
Expand Down Expand Up @@ -326,7 +328,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
{ ...target, migrate },
createStore({
sidebar: {
opened: false,
opened: true,
width: DEFAULT_SIDEBAR_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
Expand Down Expand Up @@ -731,19 +733,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
dock: {
// Deprecated compatibility metadata. New UI must use view(session).panel;
// only locations and the shared Bottom Panel dimension remain global.
// Phase 3: terminal is now side-native in the right panel (independent host).
// dock.location("terminal") is forced to "bottom" to avoid stale stored "side"
// values making the bottom terminal unreachable. Move operations for terminal
// are intentionally no-ops; only debug-console and problems remain movable.
location(id: DockPanelID): DockLocation {
if (id === "terminal") return "bottom"
return store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]
},
setLocation(id: DockPanelID, location: DockLocation) {
if (id === "terminal") return // terminal location is now fixed
if (!store.dock) {
setStore("dock", { location: { [id]: location } as Record<DockPanelID, DockLocation> })
return
}
setStore("dock", "location", id, location)
},
move(id: DockPanelID) {
if (id === "terminal") return // terminal is no longer movable
const current = store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]
const next: DockLocation = current === "bottom" ? "side" : "bottom"
if (!store.dock) {
Expand All @@ -753,7 +760,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("dock", "location", id, next)
},
bottomCount: createMemo(
() => DOCK_PANEL_IDS.filter((id) => (store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]) === "bottom").length,
() => DOCK_PANEL_IDS.filter((id) => (id === "terminal" ? true : (store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]) === "bottom")).length,
),
},
review: {
Expand Down Expand Up @@ -905,7 +912,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
const rightPanelMode = createMemo(() => store.sessionView[key()]?.rightPanelMode)
const rightPanelMode = createMemo(() => {
const mode = store.sessionView[key()]?.rightPanelMode
// Phase 2 backward-compat: "oversight" was merged into "subagents". Any session that
// persisted "oversight" should silently open the unified subagents panel instead.
if (mode === "oversight") return "subagents" as SessionRightPanelMode
return mode
})
const bottomPanel = createMemo(() => {
const current = store.sessionView[key()]
// One-way compatibility migration from the pre-panel global terminal flag.
Expand All @@ -925,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()
})
})
Loading
Loading