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/app/src/pages/session | |
| parent | 65318a80f7a3320ba77b749241f8de997dc65c82 (diff) | |
| download | opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip | |
feat(app): better subagent experience (#20708)
Diffstat (limited to 'packages/app/src/pages/session')
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-region.tsx | 61 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 419 |
2 files changed, 304 insertions, 176 deletions
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: { )} </Show> - <Show when={!props.state.blocked()}> + <Show when={showComposer()}> <Show when={prompt.ready()} fallback={ @@ -232,17 +246,40 @@ export function SessionComposerRegion(props: { onEdit={props.followup!.onEdit} /> </Show> - <PromptInput - ref={props.inputRef} - newSessionWorktree={props.newSessionWorktree} - onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} - edit={props.followup?.edit} - onEditLoaded={props.followup?.onEditLoaded} - shouldQueue={props.followup?.queue} - onQueue={props.followup?.onQueue} - onAbort={props.followup?.onAbort} - onSubmit={props.onSubmit} - /> + <Show + when={child()} + fallback={ + <Show when={!props.state.blocked()}> + <PromptInput + ref={props.inputRef} + newSessionWorktree={props.newSessionWorktree} + onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} + edit={props.followup?.edit} + onEditLoaded={props.followup?.onEditLoaded} + shouldQueue={props.followup?.queue} + onQueue={props.followup?.onQueue} + onAbort={props.followup?.onAbort} + onSubmit={props.onSubmit} + /> + </Show> + } + > + <div + ref={props.inputRef} + class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak" + > + <span>{language.t("session.child.promptDisabled")} </span> + <Show when={parentID()}> + <button + type="button" + class="text-text-base transition-colors hover:text-text-strong" + onClick={openParent} + > + {language.t("session.child.backToParent")} + </button> + </Show> + </div> + </Show> </div> </Show> </Show> 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: { <div ref={props.setContentRef} class="min-w-0 w-full"> <Show when={showHeader()}> <div + ref={(el) => { + 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, }} > + <Show when={workingStatus() !== "hidden"}> + <div + data-component="session-progress" + data-state={workingStatus()} + aria-hidden="true" + style={{ + "--session-progress-color": tint() ?? "var(--icon-interactive-base)", + "--session-progress-ms": `${bar.ms}ms`, + }} + > + <div data-component="session-progress-bar" /> + </div> + </Show> <div class="h-12 w-full flex items-center justify-between gap-2"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> - <Show when={parentID()}> - <IconButton - tabIndex={-1} - icon="arrow-left" - variant="ghost" - onClick={navigateParent} - aria-label={language.t("common.goBack")} - /> - </Show> <div class="flex items-center min-w-0 grow-1"> + <Show when={parentID()}> + <button + type="button" + data-slot="session-title-parent" + class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base" + onClick={navigateParent} + > + {parentTitle()} + </button> + <span + data-slot="session-title-separator" + class="px-2 text-14-medium text-text-weak" + aria-hidden="true" + > + / + </span> + </Show> <div class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" style={{ @@ -684,15 +771,16 @@ export function MessageTimeline(props: { </div> </Show> </div> - <Show when={titleLabel() || title.editing}> + <Show when={childTitle() || title.editing}> <Show when={title.editing} fallback={ <h1 + data-slot="session-title-child" class="text-14-medium text-text-strong truncate grow-1 min-w-0" onDblClick={openTitleEditor} > - {titleLabel()} + {childTitle()} </h1> } > @@ -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) => ( <div class="shrink-0 flex items-center gap-3"> <SessionContextUsage placement="bottom" /> - <DropdownMenu - gutter={4} - placement="bottom-end" - open={title.menuOpen} - onOpenChange={(open) => { - setTitle("menuOpen", open) - if (open) return - }} - > - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - classList={{ - "bg-surface-base-active": share.open || title.pendingShare, + <Show when={!parentID()}> + <DropdownMenu + gutter={4} + placement="bottom-end" + open={title.menuOpen} + onOpenChange={(open) => { + 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 - }} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content - style={{ "min-width": "104px" }} - onCloseAutoFocus={(event) => { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) - } + > + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md data-[expanded]:bg-surface-base-active" + classList={{ + "bg-surface-base-active": share.open || title.pendingShare, }} - > - <DropdownMenu.Item - onSelect={() => { - 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 + }} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content + style={{ "min-width": "104px" }} + onCloseAutoFocus={(event) => { + if (title.pendingRename) { + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + return + } + if (title.pendingShare) { + event.preventDefault() + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + } }} > - <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <Show when={shareEnabled()}> <DropdownMenu.Item onSelect={() => { - setTitle({ pendingShare: true, menuOpen: false }) + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - <DropdownMenu.ItemLabel> - {language.t("session.share.action.share")} - </DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <Show when={shareEnabled()}> + <DropdownMenu.Item + onSelect={() => { + setTitle({ pendingShare: true, menuOpen: false }) + }} + > + <DropdownMenu.ItemLabel> + {language.t("session.share.action.share")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <DropdownMenu.Item onSelect={() => void archiveSession(id())}> + <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Separator /> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} + > + <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <DropdownMenu.Item onSelect={() => void archiveSession(id())}> - <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Separator /> - <DropdownMenu.Item - onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + + <KobaltePopover + open={share.open} + anchorRef={() => more} + placement="bottom-end" + gutter={4} + modal={false} + onOpenChange={(open) => { + if (open) setShare("dismiss", null) + setShare("open", open) + }} + > + <KobaltePopover.Portal> + <KobaltePopover.Content + data-component="popover-content" + style={{ "min-width": "320px" }} + onEscapeKeyDown={(event) => { + 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) + }} > - <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - - <KobaltePopover - open={share.open} - anchorRef={() => more} - placement="bottom-end" - gutter={4} - modal={false} - onOpenChange={(open) => { - if (open) setShare("dismiss", null) - setShare("open", open) - }} - > - <KobaltePopover.Portal> - <KobaltePopover.Content - data-component="popover-content" - style={{ "min-width": "320px" }} - onEscapeKeyDown={(event) => { - 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) - }} - > - <div class="flex flex-col p-3"> - <div class="flex flex-col gap-1"> - <div class="text-13-medium text-text-strong"> - {language.t("session.share.popover.title")} - </div> - <div class="text-12-regular text-text-weak"> - {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} + <div class="flex flex-col p-3"> + <div class="flex flex-col gap-1"> + <div class="text-13-medium text-text-strong"> + {language.t("session.share.popover.title")} + </div> + <div class="text-12-regular text-text-weak"> + {shareUrl() + ? language.t("session.share.popover.description.shared") + : language.t("session.share.popover.description.unshared")} + </div> </div> - </div> - <div class="mt-3 flex flex-col gap-2"> - <Show - when={shareUrl()} - fallback={ - <Button - size="large" - variant="primary" - class="w-full" - onClick={shareSession} - disabled={shareMutation.isPending} - > - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - </Button> - } - > - <div class="flex flex-col gap-2"> - <TextField - value={shareUrl() ?? ""} - readOnly - copyable - copyKind="link" - tabIndex={-1} - class="w-full" - /> - <div class="grid grid-cols-2 gap-2"> - <Button - size="large" - variant="secondary" - class="w-full shadow-none border border-border-weak-base" - onClick={unshareSession} - disabled={unshareMutation.isPending} - > - {unshareMutation.isPending - ? language.t("session.share.action.unpublishing") - : language.t("session.share.action.unpublish")} - </Button> + <div class="mt-3 flex flex-col gap-2"> + <Show + when={shareUrl()} + fallback={ <Button size="large" variant="primary" class="w-full" - onClick={viewShare} - disabled={unshareMutation.isPending} + onClick={shareSession} + disabled={shareMutation.isPending} > - {language.t("session.share.action.view")} + {shareMutation.isPending + ? language.t("session.share.action.publishing") + : language.t("session.share.action.publish")} </Button> + } + > + <div class="flex flex-col gap-2"> + <TextField + value={shareUrl() ?? ""} + readOnly + copyable + copyKind="link" + tabIndex={-1} + class="w-full" + /> + <div class="grid grid-cols-2 gap-2"> + <Button + size="large" + variant="secondary" + class="w-full shadow-none border border-border-weak-base" + onClick={unshareSession} + disabled={unshareMutation.isPending} + > + {unshareMutation.isPending + ? language.t("session.share.action.unpublishing") + : language.t("session.share.action.unpublish")} + </Button> + <Button + size="large" + variant="primary" + class="w-full" + onClick={viewShare} + disabled={unshareMutation.isPending} + > + {language.t("session.share.action.view")} + </Button> + </div> </div> - </div> - </Show> + </Show> + </div> </div> - </div> - </KobaltePopover.Content> - </KobaltePopover.Portal> - </KobaltePopover> + </KobaltePopover.Content> + </KobaltePopover.Portal> + </KobaltePopover> + </Show> </div> )} </Show> |
