feat(app): better subagent experience (#20708)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 titleContent = () => <TextShimmer text={title()} active={running()} />
|
||||
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 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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user