summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/session
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/pages/session')
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx61
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx419
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>