From cf1671f737e5bca634190c162f377b9fefd84b60 Mon Sep 17 00:00:00 2001 From: gong_hang Date: Tue, 10 Feb 2026 20:09:29 +0800 Subject: [PATCH] feat: add subagent sidebar with hover navigation and project rename - Rename project from opencode to overaicoding (package.json, bin, build) - Add session tree utilities (session-tree.ts, child-session-picker.ts) - Redesign header: compact single-row layout with subagent count and nav buttons - Rewrite sidebar: root-session anchoring to prevent flicker during hover nav - Add hover-to-navigate on subagent items and parent link in sidebar - Add collapsible Subagents section with dropdown toggle - Add child session list dialog and session_child_list keybind - Enhance dialog-subagent with root/parent navigation options - Keep sidebar visible when viewing subagent sessions --- package.json | 4 +- packages/opencode/bin/overaicoding | 84 ++++++++++ packages/opencode/package.json | 4 +- packages/opencode/script/build.ts | 2 +- .../component/dialog-child-session-list.tsx | 75 +++++++++ .../cli/cmd/tui/lib/child-session-picker.ts | 100 ++++++++++++ .../src/cli/cmd/tui/lib/session-tree.ts | 110 +++++++++++++ .../tui/routes/session/dialog-subagent.tsx | 109 ++++++++++++- .../src/cli/cmd/tui/routes/session/header.tsx | 129 ++++++++++----- .../src/cli/cmd/tui/routes/session/index.tsx | 20 ++- .../cli/cmd/tui/routes/session/sidebar.tsx | 153 ++++++++++++++++-- packages/opencode/src/config/config.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/web/package.json | 2 +- 14 files changed, 733 insertions(+), 64 deletions(-) create mode 100755 packages/opencode/bin/overaicoding create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-child-session-list.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/lib/child-session-picker.ts create mode 100644 packages/opencode/src/cli/cmd/tui/lib/session-tree.ts diff --git a/package.json b/package.json index 2c69f46d2993..ed89c6351755 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "opencode", - "description": "AI-powered development tool", + "name": "overaicoding", + "description": "AI-powered development tool (custom fork)", "private": true, "type": "module", "packageManager": "bun@1.3.8", diff --git a/packages/opencode/bin/overaicoding b/packages/opencode/bin/overaicoding new file mode 100755 index 000000000000..a05ff967a84c --- /dev/null +++ b/packages/opencode/bin/overaicoding @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +function run(target) { + const result = childProcess.spawnSync(target, process.argv.slice(2), { + stdio: "inherit", + }) + if (result.error) { + console.error(result.error.message) + process.exit(1) + } + const code = typeof result.status === "number" ? result.status : 0 + process.exit(code) +} + +const envPath = process.env.OVERAICODING_BIN_PATH || process.env.OPENCODE_BIN_PATH +if (envPath) { + run(envPath) +} + +const scriptPath = fs.realpathSync(__filename) +const scriptDir = path.dirname(scriptPath) + +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", +} + +let platform = platformMap[os.platform()] +if (!platform) { + platform = os.platform() +} +let arch = archMap[os.arch()] +if (!arch) { + arch = os.arch() +} +const base = "overaicoding-" + platform + "-" + arch +const binary = platform === "windows" ? "overaicoding.exe" : "overaicoding" + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) { + const entries = fs.readdirSync(modules) + for (const entry of entries) { + if (!entry.startsWith(base)) { + continue + } + const candidate = path.join(modules, entry, "bin", binary) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + const parent = path.dirname(current) + if (parent === current) { + return + } + current = parent + } +} + +const resolved = findBinary(scriptDir) +if (!resolved) { + console.error( + 'It seems that your package manager failed to install the right version of the overaicoding CLI for your platform. You can try manually installing the "' + + base + + '" package', + ) + process.exit(1) +} + +run(resolved) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a2024a7f7c17..c7f232fb37b5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "version": "1.1.53", - "name": "opencode", + "name": "overaicoding", "type": "module", "license": "MIT", "private": true, @@ -18,7 +18,7 @@ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" }, "bin": { - "opencode": "./bin/opencode" + "overaicoding": "./bin/overaicoding" }, "randomField": "this-is-a-random-value-12345", "exports": { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index f0b3fa828a78..4e8a4da9d107 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -149,7 +149,7 @@ for (const item of targets) { autoloadTsconfig: true, autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, - outfile: `dist/${name}/bin/opencode`, + outfile: `dist/${name}/bin/overaicoding`, execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-child-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-child-session-list.tsx new file mode 100644 index 000000000000..0e1c772126a5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-child-session-list.tsx @@ -0,0 +1,75 @@ +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useTheme } from "@tui/context/theme" +import { createMemo, onMount } from "solid-js" +import { buildChildSessionPickerOptions } from "../lib/child-session-picker" +import { sessionRunState } from "../lib/session-tree" +import "opentui-spinner/solid" + +export function DialogChildSessionList(props: { sessionID: string }) { + const dialog = useDialog() + const sync = useSync() + const route = useRoute() + const { theme } = useTheme() + + onMount(() => { + dialog.setSize("large") + }) + + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + const options = createMemo(() => { + const sessions = sync.data.session.map((s) => ({ + id: s.id, + title: s.title, + parentID: s.parentID, + time: { created: s.time.created, updated: s.time.updated }, + })) + + const baseOptions = buildChildSessionPickerOptions({ + currentSessionID: props.sessionID, + sessions, + permissionsBySession: sync.data.permission, + }).options + + // Enhance options with status indicators + return baseOptions.map((opt) => { + const pending = sync.data.permission[opt.value]?.length ?? 0 + const status = sync.data.session_status?.[opt.value] as { type?: string } | undefined + const state = sessionRunState(status) + + const session = sync.data.session.find((s) => s.id === opt.value) + const isSubagent = session?.parentID !== undefined + + const gutter = (() => { + if (pending > 0) return ! + if (!isSubagent) return + if (state === "working") return + if (state === "waiting") return + return + })() + + return { + ...opt, + gutter, + } + }) + }) + + return ( + { + route.navigate({ + type: "session", + sessionID: option.value, + }) + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/lib/child-session-picker.ts b/packages/opencode/src/cli/cmd/tui/lib/child-session-picker.ts new file mode 100644 index 000000000000..3f4cbfc59cdb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/lib/child-session-picker.ts @@ -0,0 +1,100 @@ +import type { DialogSelectOption } from "@tui/ui/dialog-select" +import { Locale } from "@/util/locale" +import { buildSessionTree } from "./session-tree" + +export type ChildSessionPickerSession = { + id: string + title: string + parentID?: string + time: { + created: number + updated: number + } +} + +type PermissionBySession = Record | undefined> + +function isDefaultSessionTitle(title: string): boolean { + const prefix = + title.startsWith("New session - ") || title.startsWith("Child session - ") || title.startsWith("Subagent - ") + if (!prefix) return false + return /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) +} + +function shortID(id: string): string { + if (!id) return "" + if (id.length <= 8) return id + return id.slice(-8) +} + +function permissionCount(permissions: PermissionBySession, sessionID: string): number { + const list = permissions[sessionID] + if (!list) return 0 + return Array.isArray(list) ? list.length : 0 +} + +export function buildChildSessionPickerOptions(input: { + currentSessionID: string + sessions: ChildSessionPickerSession[] + permissionsBySession: PermissionBySession +}): { + rootID: string + options: DialogSelectOption[] +} { + const tree = buildSessionTree({ + currentSessionID: input.currentSessionID, + sessions: input.sessions, + sort: "created", + }) + + const options = tree.list.map((item) => { + const sessionID = item.id + const session = tree.sessionByID.get(sessionID) + const sid = shortID(sessionID) + + const indent = item.depth > 0 ? `${" ".repeat(item.depth - 1)}↳ ` : "" + + const title = (() => { + if (sessionID === tree.rootID) { + const name = session?.title ?? "Root session" + return `Root · ${name} · ${sid}` + } + + const name = (() => { + if (!session) return "Subagent session" + if (!isDefaultSessionTitle(session.title)) return session.title + return "Subagent session" + })() + + return `${indent}${name} · ${sid}` + })() + + const description = (() => { + const pending = permissionCount(input.permissionsBySession, sessionID) + if (pending > 0) return "Needs input" + + if (!session) return + if (sessionID === tree.rootID) return + if (!isDefaultSessionTitle(session.title)) return + return session.title + })() + + const footer = (() => { + const pending = permissionCount(input.permissionsBySession, sessionID) + if (pending > 0) return `${pending} pending` + if (session) return Locale.todayTimeOrDateTime(session.time.updated) + })() + + return { + title, + value: sessionID, + description, + footer, + } satisfies DialogSelectOption + }) + + return { + rootID: tree.rootID, + options, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/lib/session-tree.ts b/packages/opencode/src/cli/cmd/tui/lib/session-tree.ts new file mode 100644 index 000000000000..6b936027287e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/lib/session-tree.ts @@ -0,0 +1,110 @@ +export type SessionTreeSession = { + id: string + parentID?: string + time?: { + created?: number + updated?: number + } +} + +export type SessionTreeItem = { + id: string + depth: number +} + +function sessionTime(session: T | undefined, key: "created" | "updated"): number { + const time = session?.time + if (!time) return 0 + if (key === "created") return time.created ?? 0 + return time.updated ?? 0 +} + +export function resolveRootID(input: { + currentSessionID: string + sessionByID: Map +}): string { + const visited = new Set() + let current = input.currentSessionID + + while (!visited.has(current)) { + visited.add(current) + + const session = input.sessionByID.get(current) + const parent = session?.parentID + if (!parent) return current + if (!input.sessionByID.has(parent)) return parent + current = parent + } + + return input.currentSessionID +} + +export function buildSessionTree(input: { + currentSessionID: string + sessions: T[] + sort?: "created" | "updated" +}): { + rootID: string + list: SessionTreeItem[] + sessionByID: Map +} { + const sessionByID = new Map(input.sessions.map((s) => [s.id, s])) + const rootID = resolveRootID({ + currentSessionID: input.currentSessionID, + sessionByID, + }) + + const childrenByParent = new Map() + for (const session of input.sessions) { + const parent = session.parentID + if (!parent) continue + const list = childrenByParent.get(parent) + if (list) { + list.push(session.id) + continue + } + childrenByParent.set(parent, [session.id]) + } + + const sortKey = input.sort ?? "created" + for (const list of childrenByParent.values()) { + list.sort((a, b) => { + const at = sessionTime(sessionByID.get(a), sortKey) + const bt = sessionTime(sessionByID.get(b), sortKey) + if (at !== bt) return at - bt + return a.localeCompare(b) + }) + } + + const seen = new Set([rootID]) + const stack: SessionTreeItem[] = [{ id: rootID, depth: 0 }] + const out: SessionTreeItem[] = [] + + while (stack.length > 0) { + const item = stack.pop() + if (!item) continue + + out.push(item) + + const children = childrenByParent.get(item.id) ?? [] + for (const child of children.toReversed()) { + if (seen.has(child)) continue + seen.add(child) + stack.push({ id: child, depth: item.depth + 1 }) + } + } + + return { + rootID, + list: out, + sessionByID, + } +} + +export type SessionRunState = "working" | "waiting" | "done" + +export function sessionRunState(status: { type?: string } | undefined): SessionRunState { + if (status?.type === "busy" || status?.type === "retry") return "working" + if (status?.type === "waiting") return "waiting" + return "done" +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx index c5ef70ef06f6..de60f517ab9a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx @@ -1,18 +1,87 @@ +import type { DialogContext } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo } from "solid-js" +import { buildSessionTree } from "../../lib/session-tree" export function DialogSubagent(props: { sessionID: string }) { const route = useRoute() + const sync = useSync() + + const currentID = createMemo(() => { + if (route.data.type !== "session") return undefined + return route.data.sessionID + }) + + const session = createMemo(() => sync.session.get(props.sessionID)) + const status = createMemo(() => { + const s = sync.data.session_status?.[props.sessionID] as { type?: string } | undefined + if (s?.type === "busy" || s?.type === "retry") return "working" + if (s?.type === "waiting") return "waiting" + return "idle" + }) + + const name = createMemo(() => { + const s = session() + if (!s?.title) return "Subagent Session" + if (s.title.startsWith("Subagent - ")) return s.title.slice(11) + return s.title + }) + + const shortId = createMemo(() => props.sessionID.slice(-4)) + + const title = createMemo(() => { + const statusIcon = status() === "working" ? "◐" : status() === "waiting" ? "◎" : "✓" + return `${statusIcon} ${name()}#${shortId()}` + }) + + const parentID = createMemo(() => session()?.parentID) + + const rootID = createMemo(() => { + const parent = parentID() + if (!parent) return undefined + + return buildSessionTree({ + currentSessionID: props.sessionID, + sessions: sync.data.session, + sort: "created", + }).rootID + }) + + const showRoot = createMemo(() => { + const root = rootID() + if (!root) return false + + const parent = parentID() + if (!parent) return false + if (root === parent) return false + + const current = currentID() + if (!current) return true + + return root !== current + }) + + const showCaller = createMemo(() => { + const parent = parentID() + if (!parent) return false + + const current = currentID() + if (!current) return true + + return parent !== current + }) return ( { + description: "View this session", + onSelect: (dialog: DialogContext) => { route.navigate({ type: "session", sessionID: props.sessionID, @@ -20,6 +89,38 @@ export function DialogSubagent(props: { sessionID: string }) { dialog.clear() }, }, + ...(showRoot() + ? [ + { + title: "Open root session", + value: "subagent.root", + description: "Go to the primary session for this thread", + onSelect: (dialog: DialogContext) => { + const root = rootID() + if (root) { + route.navigate({ type: "session", sessionID: root }) + } + dialog.clear() + }, + }, + ] + : []), + ...(showCaller() + ? [ + { + title: "Open parent session", + value: "subagent.parent", + description: "Go to the session that spawned this subagent", + onSelect: (dialog: DialogContext) => { + const parent = parentID() + if (parent) { + route.navigate({ type: "session", sessionID: parent }) + } + dialog.clear() + }, + }, + ] + : []), ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a85723..462a2930e6cc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -7,7 +7,8 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" -import { useTerminalDimensions } from "@opentui/solid" +import { Installation } from "@/installation" +import { buildSessionTree, sessionRunState } from "../../lib/session-tree" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() @@ -35,6 +36,25 @@ export function Header() { const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const tree = createMemo(() => + buildSessionTree({ + currentSessionID: route.sessionID, + sessions: sync.data.session, + sort: "created", + }), + ) + + const childCount = createMemo(() => Math.max(0, tree().list.length - 1)) + + const workingChildCount = createMemo(() => { + const t = tree() + return t.list.filter((item) => { + if (item.id === t.rootID) return false + const status = sync.data.session_status?.[item.id] as { type?: string } | undefined + return sessionRunState(status) !== "done" + }).length + }) + const cost = createMemo(() => { const total = pipe( messages(), @@ -62,9 +82,7 @@ export function Header() { const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) - const dimensions = useTerminalDimensions() - const narrow = createMemo(() => dimensions().width < 80) + const [hover, setHover] = createSignal<"parent" | "prev" | "next" | "list" | null>(null) return ( @@ -81,51 +99,78 @@ export function Header() { > - - + + + Subagent session + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > - Subagent session + Parent {keybind.print("session_parent")} - - - setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} - > - - Parent {keybind.print("session_parent")} - - - setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} - > - - Prev {keybind.print("session_child_cycle_reverse")} - - - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - - Next {keybind.print("session_child_cycle")} - - + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.previous")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + > + + Prev {keybind.print("session_child_cycle_reverse")} + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + > + + Next {keybind.print("session_child_cycle")} + + + setHover("list")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.list")} + backgroundColor={hover() === "list" ? theme.backgroundElement : theme.backgroundPanel} + > + + List {keybind.print("session_child_list")} + + + + + + v{Installation.VERSION} - - - <ContextInfo context={context} cost={cost} /> + <box flexDirection="row" justifyContent="space-between" gap={1}> + <box flexDirection="row" gap={2}> + <Title session={session} /> + <Show when={childCount() > 0}> + <text fg={theme.textMuted}> + <span style={{ fg: workingChildCount() > 0 ? theme.warning : theme.success }}> + {workingChildCount() > 0 ? "◐" : "●"} + </span>{" "} + {childCount()} subagent{childCount() > 1 ? "s" : ""} + <Show when={workingChildCount() > 0}> + <span style={{ fg: theme.warning }}> ({workingChildCount()} working)</span> + </Show>{" "} + <span style={{ fg: theme.border }}>{keybind.print("session_child_list")}</span> + </text> + </Show> + </box> + <box flexDirection="row" gap={1} flexShrink={0}> + <ContextInfo context={context} cost={cost} /> + <text fg={theme.textMuted}>v{Installation.VERSION}</text> + </box> </box> </Match> </Switch> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 70ff5eaf9fb6..01dcf11a02fe 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { DialogChildSessionList } from "../../component/dialog-child-session-list" import { UI } from "@/cli/ui.ts" addDefaultParsers(parsers.parsers) @@ -154,7 +155,6 @@ export function Session() { const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { - if (session()?.parentID) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false @@ -885,6 +885,22 @@ export function Session() { dialog.clear() }, }, + { + title: "Switch subagent session", + value: "session.child.list", + keybind: "session_child_list", + category: "Session", + hidden: true, + onSelect: (dialog) => { + const childList = children() + if (childList.length <= 1) { + toast.show({ variant: "warning", message: "No subagent sessions found", duration: 2000 }) + dialog.clear() + return + } + dialog.replace(() => <DialogChildSessionList sessionID={route.sessionID} />) + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -958,7 +974,7 @@ export function Session() { <box flexDirection="row"> <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}> <Show when={session()}> - <Show when={!sidebarVisible() || !wide()}> + <Show when={!sidebarVisible() || !wide() || !!session()?.parentID}> <Header /> </Show> <scrollbox diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed7..6e55d9c20b70 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,5 +1,5 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" @@ -11,22 +11,62 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { buildSessionTree, sessionRunState } from "../../lib/session-tree" +import { useRoute } from "../../context/route" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() - const session = createMemo(() => sync.session.get(props.sessionID)!) - const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) - const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) - const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + const route = useRoute() const [expanded, setExpanded] = createStore({ mcp: true, diff: true, todo: true, lsp: true, + subagents: true, }) + const tree = createMemo(() => + buildSessionTree({ + currentSessionID: props.sessionID, + sessions: sync.data.session, + sort: "created", + }), + ) + + // 锚定到根会话:sidebar 始终显示根会话信息,hover 切换 subagent 时 sidebar 不会重绘 + const rootSessionID = createMemo(() => tree().rootID) + const rootSession = createMemo(() => sync.session.get(rootSessionID())!) + const isInSubagent = createMemo(() => props.sessionID !== rootSessionID()) + + // 所有展示信息都基于根会话,保持 sidebar 稳定 + const diff = createMemo(() => sync.data.session_diff[rootSessionID()] ?? []) + const todo = createMemo(() => sync.data.todo[rootSessionID()] ?? []) + const messages = createMemo(() => sync.data.message[rootSessionID()] ?? []) + + const subagentSessions = createMemo(() => { + const t = tree() + const out: Array<{ session: (typeof sync.data.session)[number]; depth: number }> = [] + + for (const item of t.list) { + if (item.id === t.rootID) continue + const s = t.sessionByID.get(item.id) + if (!s) continue + out.push({ session: s, depth: item.depth }) + } + + return out + }) + + const getSessionStatus = (sessionID: string) => { + const status = sync.data.session_status?.[sessionID] as { type?: string } | undefined + return sessionRunState(status) + } + + // Parent 悬停状态 + const [parentHovered, setParentHovered] = createSignal(false) + // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) @@ -69,11 +109,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) return ( - <Show when={session()}> + <Show when={rootSession()}> <box backgroundColor={theme.backgroundPanel} width={42} - height="100%" paddingTop={1} paddingBottom={1} paddingLeft={2} @@ -84,10 +123,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { <box flexShrink={0} gap={1} paddingRight={1}> <box paddingRight={1}> <text fg={theme.text}> - <b>{session().title}</b> + <b>{rootSession().title}</b> </text> - <Show when={session().share?.url}> - <text fg={theme.textMuted}>{session().share!.url}</text> + <Show when={rootSession().share?.url}> + <text fg={theme.textMuted}>{rootSession().share!.url}</text> </Show> </box> <box> @@ -202,6 +241,100 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { </For> </Show> </box> + {/* Subagents Section */} + <Show when={subagentSessions().length > 0}> + <box> + <box + flexDirection="row" + gap={1} + onMouseDown={() => setExpanded("subagents", !expanded.subagents)} + > + <text fg={theme.text}>{expanded.subagents ? "▼" : "▶"}</text> + <text fg={theme.text}> + <b>Subagents</b> + <Show when={!expanded.subagents}> + <span style={{ fg: theme.textMuted }}> ({subagentSessions().length})</span> + </Show> + </text> + </box> + <Show when={expanded.subagents}> + {/* Parent 链接:始终显示,避免布局移位导致闪屏 */} + <box + flexDirection="row" + gap={1} + backgroundColor={parentHovered() && isInSubagent() ? theme.backgroundElement : undefined} + onMouseOver={() => { + setParentHovered(true) + if (isInSubagent()) { + route.navigate({ type: "session", sessionID: rootSessionID() }) + } + }} + onMouseOut={() => setParentHovered(false)} + onMouseDown={() => { + if (isInSubagent()) { + route.navigate({ type: "session", sessionID: rootSessionID() }) + } + }} + > + <text fg={!isInSubagent() ? theme.primary : theme.accent}>↑</text> + <text fg={!isInSubagent() ? theme.primary : parentHovered() ? theme.text : theme.text}> + {!isInSubagent() ? <span style={{ bold: true }}>Parent</span> : <b>Parent</b>} + <span style={{ fg: !isInSubagent() ? theme.primary : parentHovered() ? theme.text : theme.textMuted }}> + {" "} + {(() => { + const title = rootSession()?.title ?? "Primary" + return title.length > 25 ? title.slice(0, 25) + "…" : title + })()} + </span> + </text> + </box> + <For each={subagentSessions()}> + {(item) => { + const sub = item.session + const status = createMemo(() => getSessionStatus(sub.id)) + const isCurrent = createMemo(() => sub.id === props.sessionID) + const [hovered, setHovered] = createSignal(false) + const statusColor = createMemo(() => { + if (status() === "working") return theme.warning + if (status() === "waiting") return theme.accent + return theme.success + }) + const statusIcon = createMemo(() => { + if (status() === "working") return "◐" + if (status() === "waiting") return "◎" + return "•" + }) + const label = createMemo(() => { + const indent = item.depth > 0 ? `${" ".repeat(item.depth - 1)}↳ ` : "" + const title = sub.title ?? sub.id.slice(0, 16) + return indent + title + }) + return ( + <box + flexDirection="row" + gap={1} + backgroundColor={hovered() && !isCurrent() ? theme.backgroundElement : undefined} + onMouseOver={() => { + setHovered(true) + if (!isCurrent()) route.navigate({ type: "session", sessionID: sub.id }) + }} + onMouseOut={() => setHovered(false)} + onMouseDown={() => !isCurrent() && route.navigate({ type: "session", sessionID: sub.id })} + > + <text flexShrink={0} fg={statusColor()}> + {statusIcon()} + </text> + <text fg={isCurrent() ? theme.primary : hovered() ? theme.text : theme.textMuted} wrapMode="word"> + {isCurrent() || hovered() ? <span style={{ bold: true }}>{label()}</span> : label()} + {status() === "waiting" && <span style={{ fg: theme.accent }}> (waiting)</span>} + </text> + </box> + ) + }} + </For> + </Show> + </box> + </Show> <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}> <box> <box diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a5300724..feb030273867 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -906,6 +906,7 @@ export namespace Config { session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"), session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), + session_child_list: z.string().optional().default("<leader>down").describe("List child sessions"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9543e5b5796d..13031c31770b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1318,6 +1318,10 @@ export type KeybindsConfig = { * Go to parent session */ session_parent?: string + /** + * List child sessions + */ + session_child_list?: string /** * Suspend terminal */ diff --git a/packages/web/package.json b/packages/web/package.json index b33c6524e4cf..caa07c4e6543 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,7 +35,7 @@ "vscode-languageserver-types": "3.17.5" }, "devDependencies": { - "opencode": "workspace:*", + "overaicoding": "workspace:*", "@types/node": "catalog:", "@astrojs/check": "0.9.6", "typescript": "catalog:"