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) --- .../session/composer/session-composer-region.tsx | 61 ++- .../app/src/pages/session/message-timeline.tsx | 419 +++++++++++++-------- 2 files changed, 304 insertions(+), 176 deletions(-) (limited to 'packages/app/src/pages/session') 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 372adef96..60447566e 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 bc211303a..fe6447c2e 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" @@ -68,6 +69,16 @@ 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 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]") @@ -295,6 +306,32 @@ 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 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 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({ @@ -317,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() @@ -398,8 +447,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() @@ -646,27 +707,53 @@ 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, }} > + +