From b568a5ce0a8e86e5f80c2686302286946be22a17 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 1 Apr 2026 06:53:18 -0500
Subject: [PATCH 1/7] feat(app): better subagent experience
---
packages/app/src/i18n/en.ts | 2 +
packages/app/src/pages/layout/helpers.test.ts | 14 +
packages/app/src/pages/layout/helpers.ts | 13 +
.../app/src/pages/layout/sidebar-items.tsx | 296 ++++++------------
.../src/pages/layout/sidebar-workspace.tsx | 1 +
packages/app/src/pages/session.tsx | 14 +-
.../composer/session-composer-region.tsx | 65 +++-
.../src/pages/session/message-timeline.tsx | 32 +-
packages/app/src/utils/agent.ts | 23 +-
packages/ui/src/components/basic-tool.css | 95 ++++++
packages/ui/src/components/basic-tool.tsx | 142 +++++----
packages/ui/src/components/collapsible.css | 5 +
packages/ui/src/components/message-part.tsx | 150 +++++++--
packages/ui/src/context/data.tsx | 4 +
14 files changed, 538 insertions(+), 318 deletions(-)
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index ace0efeb8714..c6bcc37b116f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -238,6 +238,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit",
+ "session.child.promptDisabled": "Subagent sessions cannot be prompted.",
+ "session.child.backToParent": "Back to main session.",
"prompt.example.1": "Fix a TODO in the codebase",
"prompt.example.2": "What is the tech stack of this project?",
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index 1fe52d47a0a6..988332ab7ce1 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -8,6 +8,7 @@ import {
} from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
+ childSessionOnPath,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root")
})
+ test("finds the direct child on the active session path", () => {
+ const list = [
+ session({ id: "root", directory: "/workspace" }),
+ session({ id: "child", directory: "/workspace", parentID: "root" }),
+ session({ id: "leaf", directory: "/workspace", parentID: "child" }),
+ ]
+
+ expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
+ expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
+ expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
+ expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
+ })
+
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 226098c1cd66..20aeee614b81 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -60,6 +60,19 @@ export const childMapByParent = (sessions: Session[] | undefined) => {
return map
}
+export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
+ if (!activeID || activeID === rootID) return
+ const map = new Map((sessions ?? []).map((session) => [session.id, session]))
+ let id = activeID
+
+ while (id) {
+ const session = map.get(id)
+ if (!session?.parentID) return
+ if (session.parentID === rootID) return session
+ id = session.parentID
+ }
+}
+
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree)
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 058bb5a0dbed..e56accfc8353 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -1,15 +1,12 @@
-import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { Session } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar"
-import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
-import { A, useNavigate, useParams } from "@solidjs/router"
-import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
+import { A, useParams } from "@solidjs/router"
+import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
-import { hasProjectPermissions } from "./helpers"
+import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
)
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
+
return (
@@ -73,13 +71,10 @@ export type SessionItemProps = {
slug: string
mobile?: boolean
dense?: boolean
- popover?: boolean
- children: Map
+ showTooltip?: boolean
+ showChild?: boolean
+ level?: number
sidebarExpanded: Accessor
- sidebarHovering: Accessor
- nav: Accessor
- hoverSession: Accessor
- setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise
@@ -95,116 +90,52 @@ const SessionRow = (props: {
hasPermissions: Accessor
hasError: Accessor
unseenCount: Accessor
- setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
sidebarOpened: Accessor
- warmHover: () => void
warmPress: () => void
warmFocus: () => void
- cancelHoverPrefetch: () => void
-}) => {
+}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
return (
{
- props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
-
-
}>
-
-
-
-
-
-
-
-
-
-
0}>
-
-
-
-
- {title()}
-
- )
-}
-
-const SessionHoverPreview = (props: {
- mobile?: boolean
- nav: Accessor
- hoverSession: Accessor
- session: Session
- sidebarHovering: Accessor
- hoverReady: Accessor
- hoverMessages: Accessor
- language: ReturnType
- isActive: Accessor
- slug: string
- setHoverSession: (id: string | undefined) => void
- messageLabel: (message: Message) => string | undefined
- onMessageSelect: (message: Message) => void
- trigger: JSX.Element
-}): JSX.Element => {
- let ref: HTMLDivElement | undefined
-
- return (
-
- {props.trigger}
-
- }
- open={props.hoverSession() === props.session.id}
- onOpenChange={(open) => {
- if (!open) {
- props.setHoverSession(undefined)
- return
- }
- if (!ref?.matches(":hover")) return
- props.setHoverSession(props.session.id)
- }}
- >
-
{props.language.t("session.messages.loading")} }
- >
-
-
+
0}>
+
+
+
+
+
+
+
+
+
+
+
+ 0}>
+
+
+
-
+
{title()}
+
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
- const navigate = useNavigate()
const layout = useLayout()
const language = useLanguage()
const notification = useNotification()
@@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
)
})
- const tint = createMemo(() => {
- return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
+ const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
+ const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
+ const currentChild = createMemo(() => {
+ if (!props.showChild) return
+ return childSessionOnPath(sessionStore.session, props.session.id, params.id)
})
- const hoverMessages = createMemo(() =>
- sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
- )
- const hoverReady = createMemo(() => hoverMessages() !== undefined)
- const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
- const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
- const isActive = createMemo(() => props.session.id === params.id)
-
const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
@@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}
}
- const hoverPrefetch = {
- current: undefined as ReturnType
| undefined,
- }
- const cancelHoverPrefetch = () => {
- if (hoverPrefetch.current === undefined) return
- clearTimeout(hoverPrefetch.current)
- hoverPrefetch.current = undefined
- }
- const scheduleHoverPrefetch = () => {
- warm(1, "high")
- if (hoverPrefetch.current !== undefined) return
- hoverPrefetch.current = setTimeout(() => {
- hoverPrefetch.current = undefined
- warm(2, "low")
- }, 80)
- }
-
- onCleanup(cancelHoverPrefetch)
-
- const messageLabel = (message: Message) => {
- const parts = sessionStore.part[message.id] ?? []
- const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
- return text?.text
- }
const item = (
{
hasPermissions={hasPermissions}
hasError={hasError}
unseenCount={unseenCount}
- setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened}
- warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
- cancelHoverPrefetch={cancelHoverPrefetch}
/>
)
return (
-
-
-
-
- {item}
-
- }
- >
- {
- if (!isActive())
- layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
+ <>
+
+
+
+
+ {item}
+
+ }
+ >
+ {item}
+
+
- navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+
+
+ >
+
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ void props.archiveSession(props.session)
+ }}
+ />
+
+
-
-
-
- {
- event.preventDefault()
- event.stopPropagation()
- void props.archiveSession(props.session)
- }}
- />
-
-
-
+
+ {(child) => (
+
+
+
+ )}
+
+ >
)
}
@@ -390,7 +280,6 @@ export const NewSessionItem = (props: {
dense?: boolean
sidebarExpanded: Accessor
clearHoverProjectSoon: () => void
- setHoverSession: (id: string | undefined) => void
}): JSX.Element => {
const layout = useLayout()
const language = useLanguage()
@@ -400,9 +289,8 @@ export const NewSessionItem = (props: {
{
- props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 3bf00ea424d6..dc50d813d903 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -272,6 +272,7 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile}
popover={props.popover}
children={props.children()}
+ showChild
sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index a81df9dd2779..0c67647261f7 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -429,6 +429,7 @@ export default function Page() {
}
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
@@ -1058,7 +1059,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
- if (composer.blocked()) return
+ if (composer.blocked() || isChildSession()) return
inputRef?.focus()
}
}
@@ -1127,7 +1128,10 @@ export default function Page() {
setFileTreeTab("all")
}
- const focusInput = () => inputRef?.focus()
+ const focusInput = () => {
+ if (isChildSession()) return
+ inputRef?.focus()
+ }
useSessionCommands({
navigateMessageByOffset,
@@ -1658,7 +1662,7 @@ export default function Page() {
const queueEnabled = createMemo(() => {
const id = params.id
if (!id) return false
- return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
+ return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
})
const followupText = (item: FollowupDraft) => {
@@ -1690,6 +1694,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
+ if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@@ -1820,6 +1825,7 @@ export default function Page() {
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
+ if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return
@@ -2001,7 +2007,7 @@ export default function Page() {
}}
onResponseSubmit={resumeScroll}
followup={
- params.id
+ params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index 372adef96af6..498b65633167 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -1,9 +1,11 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
+import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
+import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -43,11 +45,17 @@ export function SessionComposerRegion(props: {
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
+ const navigate = useNavigate()
const prompt = usePrompt()
const language = useLanguage()
const route = useSessionKey()
+ const sync = useSync()
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
+ const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
+ const parentID = createMemo(() => info()?.parentID)
+ const child = createMemo(() => !!parentID())
+ const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () =>
prompt
@@ -113,6 +121,12 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
+ const openParent = () => {
+ const id = parentID()
+ if (!id) return
+ navigate(`/${route.params.dir}/session/${id}`)
+ }
+
createEffect(() => {
const el = store.body
if (!el) return
@@ -156,7 +170,7 @@ export function SessionComposerRegion(props: {
)}
-
+
-
+
+
+
+ }
+ >
+
+ {language.t("session.child.promptDisabled")}
+
+
+
+
+
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index bc211303a6ac..df04d26c181a 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -295,6 +295,13 @@ export function MessageTimeline(props: {
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
+ const parent = createMemo(() => {
+ const id = parentID()
+ if (!id) return
+ return sync.session.get(id)
+ })
+ const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
+ const childTitle = createMemo(() => titleLabel() ?? (parentID() ? language.t("command.session.new") : ""))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
@@ -657,16 +664,19 @@ export function MessageTimeline(props: {
>
-
-
-
+
+
+
+ /
+
+
-
+
- {titleLabel()}
+ {childTitle()}
}
>
diff --git a/packages/app/src/utils/agent.ts b/packages/app/src/utils/agent.ts
index 390932a13698..59da53af102a 100644
--- a/packages/app/src/utils/agent.ts
+++ b/packages/app/src/utils/agent.ts
@@ -5,9 +5,30 @@ const defaults: Record = {
plan: "var(--icon-agent-plan-base)",
}
+const palette = [
+ "var(--icon-agent-ask-base)",
+ "var(--icon-agent-build-base)",
+ "var(--icon-agent-docs-base)",
+ "var(--icon-agent-plan-base)",
+ "var(--syntax-info)",
+ "var(--syntax-success)",
+ "var(--syntax-warning)",
+ "var(--syntax-property)",
+ "var(--syntax-constant)",
+ "var(--text-diff-add-base)",
+ "var(--text-diff-delete-base)",
+ "var(--icon-warning-base)",
+]
+
+function tone(name: string) {
+ let hash = 0
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
+ return palette[hash % palette.length]
+}
+
export function agentColor(name: string, custom?: string) {
if (custom) return custom
- return defaults[name] ?? defaults[name.toLowerCase()]
+ return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase())
}
export function messageAgentColor(
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
index f52a5e576203..facac12fa8b5 100644
--- a/packages/ui/src/components/basic-tool.css
+++ b/packages/ui/src/components/basic-tool.css
@@ -7,6 +7,21 @@
gap: 0px;
justify-content: flex-start;
+ &[data-clickable="true"] {
+ cursor: pointer;
+ }
+
+ &[data-hide-details="true"] {
+ [data-slot="basic-tool-tool-trigger-content"] {
+ flex: 1 1 auto;
+ max-width: 100%;
+ }
+
+ [data-slot="basic-tool-tool-info"] {
+ flex: 1 1 auto;
+ }
+ }
+
[data-slot="basic-tool-tool-trigger-content"] {
flex: 0 1 auto;
width: auto;
@@ -165,3 +180,83 @@
flex-shrink: 0;
}
}
+
+[data-component="task-tool-card"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border-base, rgba(255, 255, 255, 0.17));
+ background: color-mix(in srgb, var(--background-base) 92%, transparent);
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ [data-slot="basic-tool-tool-info-structured"] {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ [data-slot="basic-tool-tool-info-main"] {
+ flex: 1 1 auto;
+ min-width: 0;
+ align-items: center;
+ }
+
+ [data-component="task-tool-spinner"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ [data-component="spinner"] {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ [data-component="task-tool-action"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--icon-weak);
+ margin-left: auto;
+ opacity: 0;
+ transform: translateX(-4px);
+ transition:
+ opacity 0.15s ease,
+ transform 0.15s ease,
+ color 0.15s ease;
+ }
+
+ [data-component="task-tool-title"] {
+ flex-shrink: 0;
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ text-transform: capitalize;
+ }
+
+ [data-slot="basic-tool-tool-subtitle"] {
+ color: var(--text-strong);
+ }
+
+ &:hover,
+ &:focus-visible {
+ border-color: var(--border-base, rgba(255, 255, 255, 0.17));
+ background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
+
+ [data-component="task-tool-action"] {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+}
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index a02fe941b1df..7d18dfacd6f2 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -34,6 +34,9 @@ export interface BasicToolProps {
locked?: boolean
animated?: boolean
onSubtitleClick?: () => void
+ onTriggerClick?: JSX.EventHandlerUnion
+ triggerHref?: string
+ clickable?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) {
setState("open", value)
}
- return (
-
-
-
-
-
-
-
- {(trigger) => (
-
-
+ const trigger = () => (
+
+
+
+
+
+ {(title) => (
+
+
+
+
+
+
+
{
+ if (props.onSubtitleClick) {
+ e.stopPropagation()
+ props.onSubtitleClick()
+ }
}}
>
-
+ {title().subtitle}
-
-
+
+
+
+ {(arg) => (
{
- if (props.onSubtitleClick) {
- e.stopPropagation()
- props.onSubtitleClick()
- }
+ [title().argsClass ?? ""]: !!title().argsClass,
}}
>
- {trigger().subtitle}
+ {arg}
-
-
-
- {(arg) => (
-
- {arg}
-
- )}
-
-
-
-
-
- {trigger().action}
+ )}
+
-
- )}
-
- {props.trigger as JSX.Element}
-
-
-
-
-
-
+
+
+
+ {title().action}
+
+
+ )}
+
+
{props.trigger as JSX.Element}
+
-
+
+
+
+
+
+ )
+
+ return (
+
+
+ {trigger()}
+
+ }
+ >
+ {(href) => (
+
+ {trigger()}
+
+ )}
+
= {
+ ask: "var(--icon-agent-ask-base)",
+ build: "var(--icon-agent-build-base)",
+ docs: "var(--icon-agent-docs-base)",
+ plan: "var(--icon-agent-plan-base)",
+}
+
+const agentPalette = [
+ "var(--icon-agent-ask-base)",
+ "var(--icon-agent-build-base)",
+ "var(--icon-agent-docs-base)",
+ "var(--icon-agent-plan-base)",
+ "var(--syntax-info)",
+ "var(--syntax-success)",
+ "var(--syntax-warning)",
+ "var(--syntax-property)",
+ "var(--syntax-constant)",
+ "var(--text-diff-add-base)",
+ "var(--text-diff-delete-base)",
+ "var(--icon-warning-base)",
+]
+
+function tone(name: string) {
+ let hash = 0
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
+ return agentPalette[hash % agentPalette.length]
+}
+
+function taskAgent(
+ raw: unknown,
+ list?: readonly { name: string; color?: string }[],
+): { name?: string; color?: string } {
+ if (typeof raw !== "string" || !raw) return {}
+ const key = raw.toLowerCase()
+ const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
+ return {
+ name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
+ color: item?.color ?? agentTones[key] ?? tone(key),
+ }
+}
+
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
const i18n = useI18n()
switch (tool) {
@@ -402,6 +445,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
return `${path.slice(0, idx)}/session/${id}`
}
+function currentSession(path: string) {
+ return path.match(/\/session\/([^/?#]+)/)?.[1]
+}
+
+function taskSession(
+ input: Record
,
+ path: string,
+ sessions: Session[] | undefined,
+ agents?: readonly { name: string; color?: string }[],
+) {
+ const parentID = currentSession(path)
+ if (!parentID) return
+ const description = typeof input.description === "string" ? input.description : ""
+ const agent = taskAgent(input.subagent_type, agents).name
+ return (sessions ?? [])
+ .filter((session) => session.parentID === parentID && !session.time?.archived)
+ .filter((session) => (description ? session.title.startsWith(description) : true))
+ .filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
+ .sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
+}
+
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite"])
@@ -1678,13 +1742,14 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const location = useLocation()
- const childSessionId = () => props.metadata.sessionId as string | undefined
- const type = createMemo(() => {
- const raw = props.input.subagent_type
- if (typeof raw !== "string" || !raw) return undefined
- return raw[0]!.toUpperCase() + raw.slice(1)
+ const childSessionId = createMemo(() => {
+ const value = props.metadata.sessionId
+ if (typeof value === "string" && value) return value
+ return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
})
- const title = createMemo(() => agentTitle(i18n, type()))
+ const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
+ const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
+ const tone = createMemo(() => agent().color)
const subtitle = createMemo(() => {
const value = props.input.description
if (typeof value === "string" && value) return value
@@ -1693,37 +1758,62 @@ ToolRegistry.register({
const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
+ const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
+
+ const open = () => {
+ const id = childSessionId()
+ if (!id) return
+ if (data.navigateToSession) {
+ data.navigateToSession(id)
+ return
+ }
+ const value = href()
+ if (value) window.location.assign(value)
+ }
- const titleContent = () =>
+ const navigate = (event: MouseEvent) => {
+ if (!data.navigateToSession) return
+ if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
+ event.preventDefault()
+ open()
+ }
const trigger = () => (
-
-
-
- {titleContent()}
-
-
-
-
- e.stopPropagation()}
- >
- {subtitle()}
-
-
-
- {subtitle()}
-
-
-
+
+
+
+
+
+
+
+
+
+ {title()}
+
+
+ {subtitle()}
+
+
+
+
+
+
+
)
- return
+ return (
+
+ )
},
})
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index e116199eb233..93368c2a0506 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -3,6 +3,10 @@ import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
type Data = {
+ agent?: {
+ name: string
+ color?: string
+ }[]
provider?: ProviderListResponse
session: Session[]
session_status: {
From 1973aa57c1bef8afdffc55e1d42c4091cf361537 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:15:43 -0500
Subject: [PATCH 2/7] chore: update test
---
.../app/e2e/session/session-child-navigation.spec.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts
index 34a1a9e2e745..4b2122384690 100644
--- a/packages/app/e2e/session/session-child-navigation.spec.ts
+++ b/packages/app/e2e/session/session-child-navigation.spec.ts
@@ -1,7 +1,6 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
-import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
test.setTimeout(120_000)
@@ -30,15 +29,16 @@ test("task tool child-session link does not trigger stale show errors", async ({
await project.gotoSession(session.id)
- const link = page
- .locator("a.subagent-link")
+ const card = page
+ .locator('[data-component="task-tool-card"]')
.filter({ hasText: /open child session/i })
.first()
- await expect(link).toBeVisible({ timeout: 30_000 })
- await link.click()
+ await expect(card).toBeVisible({ timeout: 30_000 })
+ await card.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
- await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
} finally {
From 59ce03edad680f23d248c4739dae28678626d3bb Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Fri, 3 Apr 2026 10:37:00 -0500
Subject: [PATCH 3/7] fix(app): remove message nav popover
---
packages/app/src/pages/layout.tsx | 17 ------------
packages/app/src/pages/layout/helpers.ts | 14 ----------
.../app/src/pages/layout/sidebar-project.tsx | 27 ++++---------------
.../src/pages/layout/sidebar-workspace.tsx | 25 +----------------
4 files changed, 6 insertions(+), 77 deletions(-)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 79b9abd33284..f402f4bc04df 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({
autoselect: !initialDirectory,
busyWorkspaces: {} as Record
,
- hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined,
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
- setState("hoverSession", undefined)
},
})
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
- const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
const reset = () => {
disarm()
- setState("hoverSession", undefined)
setHoverProject(undefined)
}
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
- setState("hoverSession", undefined)
}, 300)
}
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
navList: currentSessions,
sidebarExpanded,
sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,
- nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
sessionProps: {
navList: currentSessions,
sidebarExpanded,
- sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
},
- setHoverSession,
}
const SidebarPanel = (panelProps: {
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
- const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => {
const item = project()
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
- popover={popover()}
/>
>
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
- popover={popover()}
/>
)}
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 20aeee614b81..48158debba1d 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -46,20 +46,6 @@ export function hasProjectPermissions
(
return Object.values(request ?? {}).some((list) => list?.some(include))
}
-export const childMapByParent = (sessions: Session[] | undefined) => {
- const map = new Map()
- for (const session of sessions ?? []) {
- if (!session.parentID) continue
- const existing = map.get(session.parentID)
- if (existing) {
- existing.push(session.id)
- continue
- }
- map.set(session.parentID, [session.id])
- }
- return map
-}
-
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
if (!activeID || activeID === rootID) return
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
index aff0645dd894..7c9ae1aafba6 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
-import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
+import { displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor
sidebarHovering: Accessor
hoverProject: Accessor
- nav: Accessor
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
- sessionProps: Omit
- setHoverSession: (id: string | undefined) => void
+ sessionProps: Omit
}
export const ProjectDragOverlay = (props: {
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: {
project: LocalProject
mobile?: boolean
- nav: Accessor
sidebarHovering: Accessor
selected: Accessor
active: Accessor
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor
label: (directory: string) => string
projectSessions: Accessor>
- projectChildren: Accessor