diff options
| author | Adam <[email protected]> | 2026-04-07 11:06:23 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-07 11:06:23 -0500 |
| commit | ec8b9810b4231cd6a5c69ccd930b6c50999fc997 (patch) | |
| tree | 562313d6dd3eda9891f3a4a3a2ef6ce3d36acd05 /packages/ui/src | |
| parent | 65318a80f7a3320ba77b749241f8de997dc65c82 (diff) | |
| download | opencode-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.css | 92 | ||||
| -rw-r--r-- | packages/ui/src/components/basic-tool.tsx | 142 | ||||
| -rw-r--r-- | packages/ui/src/components/collapsible.css | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 150 | ||||
| -rw-r--r-- | packages/ui/src/context/data.tsx | 4 |
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: { |
