From ec8b9810b4231cd6a5c69ccd930b6c50999fc997 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:06:23 -0500 Subject: feat(app): better subagent experience (#20708) --- packages/ui/src/components/basic-tool.css | 92 +++++++++++++++++ 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 + 5 files changed, 307 insertions(+), 86 deletions(-) (limited to 'packages/ui/src') 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 + 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 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: { -- cgit v1.2.3