summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-04-07 11:06:23 -0500
committerGitHub <[email protected]>2026-04-07 11:06:23 -0500
commitec8b9810b4231cd6a5c69ccd930b6c50999fc997 (patch)
tree562313d6dd3eda9891f3a4a3a2ef6ce3d36acd05 /packages/ui/src
parent65318a80f7a3320ba77b749241f8de997dc65c82 (diff)
downloadopencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz
opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip
feat(app): better subagent experience (#20708)
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/basic-tool.css92
-rw-r--r--packages/ui/src/components/basic-tool.tsx142
-rw-r--r--packages/ui/src/components/collapsible.css5
-rw-r--r--packages/ui/src/components/message-part.tsx150
-rw-r--r--packages/ui/src/context/data.tsx4
5 files changed, 307 insertions, 86 deletions
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
index f52a5e576..198412dcb 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,80 @@
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-weak-base, rgba(255, 255, 255, 0.08));
+ 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;
+ transition:
+ opacity 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-weak-base, rgba(255, 255, 255, 0.08));
+ background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
+
+ [data-component="task-tool-action"] {
+ opacity: 1;
+ }
+ }
+}
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index a02fe941b..7d18dfacd 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<HTMLElement, MouseEvent>
+ 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 (
- <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
- <Collapsible.Trigger>
- <div data-component="tool-trigger">
- <div data-slot="basic-tool-tool-trigger-content">
- <div data-slot="basic-tool-tool-info">
- <Switch>
- <Match when={isTriggerTitle(props.trigger) && props.trigger}>
- {(trigger) => (
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
+ const trigger = () => (
+ <div
+ data-component="tool-trigger"
+ data-clickable={props.clickable ? "true" : undefined}
+ data-hide-details={props.hideDetails ? "true" : undefined}
+ >
+ <div data-slot="basic-tool-tool-trigger-content">
+ <div data-slot="basic-tool-tool-info">
+ <Switch>
+ <Match when={isTriggerTitle(props.trigger) && props.trigger}>
+ {(title) => (
+ <div data-slot="basic-tool-tool-info-structured">
+ <div data-slot="basic-tool-tool-info-main">
+ <span
+ data-slot="basic-tool-tool-title"
+ classList={{
+ [title().titleClass ?? ""]: !!title().titleClass,
+ }}
+ >
+ <TextShimmer text={title().title} active={pending()} />
+ </span>
+ <Show when={!pending()}>
+ <Show when={title().subtitle}>
<span
- data-slot="basic-tool-tool-title"
+ data-slot="basic-tool-tool-subtitle"
classList={{
- [trigger().titleClass ?? ""]: !!trigger().titleClass,
+ [title().subtitleClass ?? ""]: !!title().subtitleClass,
+ clickable: !!props.onSubtitleClick,
+ }}
+ onClick={(e) => {
+ if (props.onSubtitleClick) {
+ e.stopPropagation()
+ props.onSubtitleClick()
+ }
}}
>
- <TextShimmer text={trigger().title} active={pending()} />
+ {title().subtitle}
</span>
- <Show when={!pending()}>
- <Show when={trigger().subtitle}>
+ </Show>
+ <Show when={title().args?.length}>
+ <For each={title().args}>
+ {(arg) => (
<span
- data-slot="basic-tool-tool-subtitle"
+ data-slot="basic-tool-tool-arg"
classList={{
- [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
- clickable: !!props.onSubtitleClick,
- }}
- onClick={(e) => {
- if (props.onSubtitleClick) {
- e.stopPropagation()
- props.onSubtitleClick()
- }
+ [title().argsClass ?? ""]: !!title().argsClass,
}}
>
- {trigger().subtitle}
+ {arg}
</span>
- </Show>
- <Show when={trigger().args?.length}>
- <For each={trigger().args}>
- {(arg) => (
- <span
- data-slot="basic-tool-tool-arg"
- classList={{
- [trigger().argsClass ?? ""]: !!trigger().argsClass,
- }}
- >
- {arg}
- </span>
- )}
- </For>
- </Show>
- </Show>
- </div>
- <Show when={!pending() && trigger().action}>
- <span data-slot="basic-tool-tool-action">{trigger().action}</span>
+ )}
+ </For>
</Show>
- </div>
- )}
- </Match>
- <Match when={true}>{props.trigger as JSX.Element}</Match>
- </Switch>
- </div>
- </div>
- <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
- <Collapsible.Arrow />
- </Show>
+ </Show>
+ </div>
+ <Show when={!pending() && title().action}>
+ <span data-slot="basic-tool-tool-action">{title().action}</span>
+ </Show>
+ </div>
+ )}
+ </Match>
+ <Match when={true}>{props.trigger as JSX.Element}</Match>
+ </Switch>
</div>
- </Collapsible.Trigger>
+ </div>
+ <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
+ <Collapsible.Arrow />
+ </Show>
+ </div>
+ )
+
+ return (
+ <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
+ <Show
+ when={props.triggerHref}
+ fallback={
+ <Collapsible.Trigger
+ data-hide-details={props.hideDetails ? "true" : undefined}
+ onClick={props.onTriggerClick}
+ >
+ {trigger()}
+ </Collapsible.Trigger>
+ }
+ >
+ {(href) => (
+ <Collapsible.Trigger
+ as="a"
+ href={href()}
+ data-hide-details={props.hideDetails ? "true" : undefined}
+ onClick={props.onTriggerClick}
+ >
+ {trigger()}
+ </Collapsible.Trigger>
+ )}
+ </Show>
<Show when={props.animated && props.children && !props.hideDetails}>
<div
ref={contentRef}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index a999f6298..608ca6e0e 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -62,6 +62,11 @@
cursor: not-allowed;
}
+ &[data-hide-details="true"] {
+ height: auto;
+ align-items: stretch;
+ }
+
[data-slot="collapsible-arrow"] {
flex-shrink: 0;
width: 24px;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 03477e5a7..3627eca40 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -22,6 +22,7 @@ import {
Message as MessageType,
Part as PartType,
ReasoningPart,
+ Session,
TextPart,
ToolPart,
UserMessage,
@@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
+import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
@@ -274,6 +276,47 @@ function agentTitle(i18n: UiI18n, type?: string) {
return i18n.t("ui.tool.agent", { type })
}
+const agentTones: Record<string, string> = {
+ 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<string, any>,
+ 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 = () => <TextShimmer text={title()} active={running()} />
+ 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 = () => (
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
- <span data-slot="basic-tool-tool-title" class="capitalize agent-title">
- {titleContent()}
- </span>
- <Show when={subtitle()}>
- <Switch>
- <Match when={href()}>
- <a
- data-slot="basic-tool-tool-subtitle"
- class="clickable subagent-link"
- href={href()!}
- onClick={(e) => e.stopPropagation()}
- >
- {subtitle()}
- </a>
- </Match>
- <Match when={true}>
- <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
- </Match>
- </Switch>
- </Show>
+ <div data-component="task-tool-card">
+ <div data-slot="basic-tool-tool-info-structured">
+ <div data-slot="basic-tool-tool-info-main">
+ <Show when={running()}>
+ <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
+ <Spinner />
+ </span>
+ </Show>
+ <span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
+ {title()}
+ </span>
+ <Show when={subtitle()}>
+ <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
+ </Show>
+ </div>
</div>
+ <Show when={clickable()}>
+ <div data-component="task-tool-action">
+ <Icon name="square-arrow-top-right" size="small" />
+ </div>
+ </Show>
</div>
)
- return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
+ return (
+ <BasicTool
+ icon="task"
+ status={props.status}
+ trigger={trigger()}
+ hideDetails
+ triggerHref={href()}
+ clickable={clickable()}
+ onTriggerClick={navigate}
+ />
+ )
},
})
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index e116199eb..93368c2a0 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: {