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,
}}
>
+
+
+
-
-
-
+
+
+
+ /
+
+
-
+
- {titleLabel()}
+ {childTitle()}
}
>
@@ -700,6 +788,7 @@ export function MessageTimeline(props: {
ref={(el) => {
titleRef = el
}}
+ data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -727,177 +816,179 @@ export function MessageTimeline(props: {
{(id) => (
-
{
- setTitle("menuOpen", open)
- if (open) return
- }}
- >
-
+ {
+ setTitle("menuOpen", open)
+ if (open) return
}}
- aria-label={language.t("common.moreOptions")}
- aria-expanded={title.menuOpen || share.open || title.pendingShare}
- ref={(el: HTMLButtonElement) => {
- more = el
- }}
- />
-
- {
- if (title.pendingRename) {
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- return
- }
- if (title.pendingShare) {
- event.preventDefault()
- requestAnimationFrame(() => {
- setShare({ open: true, dismiss: null })
- setTitle("pendingShare", false)
- })
- }
+ >
+
- {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
+ aria-label={language.t("common.moreOptions")}
+ aria-expanded={title.menuOpen || share.open || title.pendingShare}
+ ref={(el: HTMLButtonElement) => {
+ more = el
+ }}
+ />
+
+ {
+ if (title.pendingRename) {
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ return
+ }
+ if (title.pendingShare) {
+ event.preventDefault()
+ requestAnimationFrame(() => {
+ setShare({ open: true, dismiss: null })
+ setTitle("pendingShare", false)
+ })
+ }
}}
>
- {language.t("common.rename")}
-
-
{
- setTitle({ pendingShare: true, menuOpen: false })
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
}}
>
-
- {language.t("session.share.action.share")}
-
+ {language.t("common.rename")}
+
+
+ {
+ setTitle({ pendingShare: true, menuOpen: false })
+ }}
+ >
+
+ {language.t("session.share.action.share")}
+
+
+
+ void archiveSession(id())}>
+ {language.t("common.archive")}
+
+
+ dialog.show(() => )}
+ >
+ {language.t("common.delete")}
-
- void archiveSession(id())}>
- {language.t("common.archive")}
-
-
- dialog.show(() => )}
+
+
+
+
+ more}
+ placement="bottom-end"
+ gutter={4}
+ modal={false}
+ onOpenChange={(open) => {
+ if (open) setShare("dismiss", null)
+ setShare("open", open)
+ }}
+ >
+
+ {
+ setShare({ dismiss: "escape", open: false })
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onPointerDownOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onFocusOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onCloseAutoFocus={(event) => {
+ if (share.dismiss === "outside") event.preventDefault()
+ setShare("dismiss", null)
+ }}
>
- {language.t("common.delete")}
-
-
-
-
-
-
more}
- placement="bottom-end"
- gutter={4}
- modal={false}
- onOpenChange={(open) => {
- if (open) setShare("dismiss", null)
- setShare("open", open)
- }}
- >
-
- {
- setShare({ dismiss: "escape", open: false })
- event.preventDefault()
- event.stopPropagation()
- }}
- onPointerDownOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onFocusOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onCloseAutoFocus={(event) => {
- if (share.dismiss === "outside") event.preventDefault()
- setShare("dismiss", null)
- }}
- >
-
-
-
- {language.t("session.share.popover.title")}
-
-
- {shareUrl()
- ? language.t("session.share.popover.description.shared")
- : language.t("session.share.popover.description.unshared")}
+
+
+
+ {language.t("session.share.popover.title")}
+
+
+ {shareUrl()
+ ? language.t("session.share.popover.description.shared")
+ : language.t("session.share.popover.description.unshared")}
+
-
-
-
- {shareMutation.isPending
- ? language.t("session.share.action.publishing")
- : language.t("session.share.action.publish")}
-
- }
- >
-
-
-
-
+
+
- {language.t("session.share.action.view")}
+ {shareMutation.isPending
+ ? language.t("session.share.action.publishing")
+ : language.t("session.share.action.publish")}
+ }
+ >
+
+
+
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
)}
--
cgit v1.2.3