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 ace0efeb871..c6bcc37b116 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 1fe52d47a0a..988332ab7ce 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 226098c1cd6..20aeee614b8 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 058bb5a0dbe..e56accfc835 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 3bf00ea424d..dc50d813d90 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 a81df9dd277..0c67647261f 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 372adef96af..498b6563316 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 bc211303a6a..df04d26c181 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 390932a1369..59da53af102 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 f52a5e57620..facac12fa8b 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 a02fe941b1d..7d18dfacd6f 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 e116199eb23..93368c2a050 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 34a1a9e2e74..4b212238469 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 79b9abd3328..f402f4bc04d 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 20aeee614b8..48158debba1 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 aff0645dd89..7c9ae1aafba 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> workspaceSessions: (directory: string) => ReturnType - workspaceChildren: (directory: string) => Map ctx: ProjectSidebarContext language: ReturnType }): JSX.Element => ( @@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: { list={props.projectSessions()} slug={base64Encode(props.project.worktree)} dense + showTooltip mobile={props.mobile} - popover={false} - children={props.projectChildren()} /> )} @@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: { {(directory) => { const sessions = createMemo(() => props.workspaceSessions(directory)) - const children = createMemo(() => props.workspaceChildren(directory)) return (
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: { list={sessions()} slug={base64Encode(directory)} dense + showTooltip mobile={props.mobile} - popover={false} - children={children()} /> )} @@ -310,20 +302,14 @@ export const SortableProject = (props: { const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) - const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) return sortedRootSessions(data, props.sortNow()) } - const workspaceChildren = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - return childMapByParent(data.session) - } const tile = () => ( diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index dc50d813d90..68e36ff77ae 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers" +import { sortedRootSessions, workspaceKey } from "./helpers" type InlineEditorComponent = (props: { id: string @@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = { navList: Accessor 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 @@ -152,7 +149,6 @@ const WorkspaceActions = (props: { showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] root: string - setHoverSession: WorkspaceSidebarContext["setHoverSession"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] navigateToNewSession: () => void }): JSX.Element => ( @@ -226,7 +222,6 @@ const WorkspaceActions = (props: { onClick={(event) => { event.preventDefault() event.stopPropagation() - props.setHoverSession(undefined) props.clearHoverProjectSoon() props.navigateToNewSession() }} @@ -239,12 +234,10 @@ const WorkspaceActions = (props: { const WorkspaceSessionList = (props: { slug: Accessor mobile?: boolean - popover?: boolean ctx: WorkspaceSidebarContext showNew: Accessor loading: Accessor sessions: Accessor - children: Accessor> hasMore: Accessor loadMore: () => Promise language: ReturnType @@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: { mobile={props.mobile} sidebarExpanded={props.ctx.sidebarExpanded} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - setHoverSession={props.ctx.setHoverSession} /> @@ -270,14 +262,8 @@ const WorkspaceSessionList = (props: { navList={props.ctx.navList} slug={props.slug()} mobile={props.mobile} - popover={props.popover} - children={props.children()} showChild sidebarExpanded={props.ctx.sidebarExpanded} - sidebarHovering={props.ctx.sidebarHovering} - nav={props.ctx.nav} - hoverSession={props.ctx.hoverSession} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} @@ -308,7 +294,6 @@ export const SortableWorkspace = (props: { project: LocalProject sortNow: Accessor mobile?: boolean - popover?: boolean }): JSX.Element => { const navigate = useNavigate() const params = useParams() @@ -322,7 +307,6 @@ export const SortableWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) - const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -429,7 +413,6 @@ export const SortableWorkspace = (props: { showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} root={props.project.worktree} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} navigateToNewSession={() => navigate(`/${slug()}/session`)} /> @@ -441,12 +424,10 @@ export const SortableWorkspace = (props: { mobile?: boolean - popover?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() @@ -472,7 +452,6 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) @@ -490,12 +469,10 @@ export const LocalWorkspace = (props: { false} loading={loading} sessions={sessions} - children={children} hasMore={hasMore} loadMore={loadMore} language={language} From a8a58e6222b3ea17834e77a9c6a97c66178b5bf7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:22:55 -0500 Subject: [PATCH 4/7] chore: cleanup subagent header --- .../session/session-child-navigation.spec.ts | 17 + .../src/pages/session/message-timeline.tsx | 358 ++++++++++-------- 2 files changed, 220 insertions(+), 155 deletions(-) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 4b212238469..c9fad1af853 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -29,6 +29,9 @@ test("task tool child-session link does not trigger stale show errors", async ({ await project.gotoSession(session.id) + const header = page.locator("[data-session-title]") + await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) + const card = page .locator('[data-component="task-tool-card"]') .filter({ hasText: /open child session/i }) @@ -37,6 +40,20 @@ test("task tool child-session link does not trigger stale show errors", async ({ await card.click() await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) + await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title) + await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description) + await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/") + await expect + .poll( + () => + header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({ + left: getComputedStyle(el).paddingLeft, + right: getComputedStyle(el).paddingRight, + })), + { timeout: 30_000 }, + ) + .toEqual({ left: "8px", right: "8px" }) + await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0) 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([]) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index df04d26c181..4ecc2119dd0 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -68,6 +68,14 @@ const messageComments = (parts: Part[]): MessageComment[] => ] }) +const taskDescription = (part: Part, sessionID: string) => { + if (part.type !== "tool" || part.tool !== "task") return + const metadata = "metadata" in part.state ? part.state.metadata : undefined + if (metadata?.sessionId !== sessionID) return + const value = part.state.input?.description + if (typeof value === "string" && value) return value +} + const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined const nested = current?.closest("[data-scrollable]") @@ -300,8 +308,27 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) + const parentMessages = createMemo(() => { + const id = parentID() + if (!id) return emptyMessages + return sync.data.message[id] ?? emptyMessages + }) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) - const childTitle = createMemo(() => titleLabel() ?? (parentID() ? language.t("command.session.new") : "")) + const childTaskDescription = createMemo(() => { + const id = sessionID() + if (!id) return + return parentMessages() + .flatMap((message) => sync.data.part[message.id] ?? []) + .map((part) => taskDescription(part, id)) + .findLast((value): value is string => !!value) + }) + const childTitle = createMemo(() => { + if (!parentID()) return titleLabel() ?? "" + if (childTaskDescription()) return childTaskDescription() + const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "") + if (value) return value + return language.t("command.session.new") + }) const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ @@ -405,8 +432,20 @@ export function MessageTimeline(props: { ), ) + createEffect( + on( + () => [parentID(), childTaskDescription()] as const, + ([id, description]) => { + if (!id || description) return + if (sync.data.message[id] !== undefined) return + void sync.session.sync(id) + }, + { defer: true }, + ), + ) + const openTitleEditor = () => { - if (!sessionID()) return + if (!sessionID() || parentID()) return setTitle({ editing: true, draft: titleLabel() ?? "" }) requestAnimationFrame(() => { titleRef?.focus() @@ -668,12 +707,17 @@ export function MessageTimeline(props: { - @@ -699,6 +743,7 @@ export function MessageTimeline(props: { when={title.editing} fallback={

@@ -710,6 +755,7 @@ export function MessageTimeline(props: { ref={(el) => { titleRef = el }} + data-slot="session-title-child" value={title.draft} disabled={titleMutation.isPending} class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" @@ -737,177 +783,179 @@ export function MessageTimeline(props: { {(id) => (
- { - setTitle("menuOpen", open) - if (open) return - }} - > - + { + setTitle("menuOpen", open) + if (open) return }} - aria-label={language.t("common.moreOptions")} - aria-expanded={title.menuOpen || share.open || title.pendingShare} - ref={(el: HTMLButtonElement) => { - more = el - }} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) - } + > + - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) + aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen || share.open || title.pendingShare} + ref={(el: HTMLButtonElement) => { + more = el + }} + /> + + { + if (title.pendingRename) { + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + return + } + if (title.pendingShare) { + event.preventDefault() + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + } }} > - {language.t("common.rename")} - - { - setTitle({ pendingShare: true, menuOpen: false }) + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - - {language.t("session.share.action.share")} - + {language.t("common.rename")} + + + { + setTitle({ pendingShare: true, menuOpen: false }) + }} + > + + {language.t("session.share.action.share")} + + + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} + + + + + more} + placement="bottom-end" + gutter={4} + modal={false} + onOpenChange={(open) => { + if (open) setShare("dismiss", null) + setShare("open", open) + }} + > + + { + setShare({ dismiss: "escape", open: false }) + event.preventDefault() + event.stopPropagation() + }} + onPointerDownOutside={() => { + setShare({ dismiss: "outside", open: false }) + }} + onFocusOutside={() => { + setShare({ dismiss: "outside", open: false }) + }} + onCloseAutoFocus={(event) => { + if (share.dismiss === "outside") event.preventDefault() + setShare("dismiss", null) + }} > - {language.t("common.delete")} - - - - - - more} - placement="bottom-end" - gutter={4} - modal={false} - onOpenChange={(open) => { - if (open) setShare("dismiss", null) - setShare("open", open) - }} - > - - { - setShare({ dismiss: "escape", open: false }) - event.preventDefault() - event.stopPropagation() - }} - onPointerDownOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onFocusOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onCloseAutoFocus={(event) => { - if (share.dismiss === "outside") event.preventDefault() - setShare("dismiss", null) - }} - > -
-
-
- {language.t("session.share.popover.title")} -
-
- {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} +
+
+
+ {language.t("session.share.popover.title")} +
+
+ {shareUrl() + ? language.t("session.share.popover.description.shared") + : language.t("session.share.popover.description.unshared")} +
-
-
- - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - - } - > -
- -
- +
+ - {language.t("session.share.action.view")} + {shareMutation.isPending + ? language.t("session.share.action.publishing") + : language.t("session.share.action.publish")} + } + > +
+ +
+ + +
-
- + +
-
- - - + + + +
)} From c8a9e1b7859143e162cc52204c0a789645a565ab Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:25:43 -0500 Subject: [PATCH 5/7] chore: cleanup --- .../src/pages/session/composer/session-composer-region.tsx | 6 +----- packages/ui/src/components/basic-tool.css | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) 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 498b6563316..60447566ed0 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -266,11 +266,7 @@ export function SessionComposerRegion(props: { >
{language.t("session.child.promptDisabled")} diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index facac12fa8b..6357b78a14d 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -189,7 +189,7 @@ gap: 8px; padding: 8px 12px; border-radius: 6px; - border: 1px solid var(--border-base, rgba(255, 255, 255, 0.17)); + border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); background: color-mix(in srgb, var(--background-base) 92%, transparent); transition: border-color 0.15s ease, @@ -251,7 +251,7 @@ &:hover, &:focus-visible { - border-color: var(--border-base, rgba(255, 255, 255, 0.17)); + border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08)); background: color-mix(in srgb, var(--background-stronger) 88%, transparent); [data-component="task-tool-action"] { From 6b9c714d8e4e69bd092eeb524538b50fccfec495 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:02:03 -0500 Subject: [PATCH 6/7] feat(app): better busy state --- packages/app/src/index.css | 40 +++++++++++++++++++ .../src/pages/session/message-timeline.tsx | 33 +++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d285..629ac80a869 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,6 +1,46 @@ @import "@opencode-ai/ui/styles/tailwind"; @layer components { + @keyframes session-progress-whip { + 0% { + clip-path: inset(0 100% 0 0 round 999px); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + + 48% { + clip-path: inset(0 0 0 0 round 999px); + animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); + } + + 100% { + clip-path: inset(0 0 0 100% round 999px); + } + } + + [data-component="session-progress"] { + position: absolute; + inset: 0 0 auto; + height: 2px; + overflow: hidden; + pointer-events: none; + opacity: 1; + transition: opacity 220ms ease-out; + } + + [data-component="session-progress"][data-state="hiding"] { + opacity: 0; + } + + [data-component="session-progress-bar"] { + width: 100%; + height: 100%; + border-radius: 999px; + background: var(--session-progress-color); + clip-path: inset(0 100% 0 0 round 999px); + animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite; + will-change: clip-path; + } + [data-component="getting-started"] { container-type: inline-size; container-name: getting-started; diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 4ecc2119dd0..fe6447c2e8c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useGlobalSDK } from "@/context/global-sdk" @@ -76,6 +77,8 @@ const taskDescription = (part: Part, sessionID: string) => { if (typeof value === "string" && value) return value } +const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900))) + const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined const nested = current?.closest("[data-scrollable]") @@ -351,8 +354,20 @@ export function MessageTimeline(props: { open: false, dismiss: null as "escape" | "outside" | null, }) + const [bar, setBar] = createStore({ + ms: pace(640), + }) let more: HTMLButtonElement | undefined + let head: HTMLDivElement | undefined + + createResizeObserver( + () => head, + () => { + if (!head || head.clientWidth <= 0) return + setBar("ms", pace(head.clientWidth)) + }, + ) const viewShare = () => { const url = shareUrl() @@ -692,15 +707,33 @@ export function MessageTimeline(props: {
{ + head = el + setBar("ms", pace(el.clientWidth)) + }} data-session-title classList={{ "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, + relative: true, "w-full": true, "pb-4": true, "pl-2 pr-3 md:pl-4 md:pr-3": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > + +