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 | |
| parent | 65318a80f7a3320ba77b749241f8de997dc65c82 (diff) | |
| download | opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip | |
feat(app): better subagent experience (#20708)
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 17 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.test.ts | 14 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.ts | 21 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-items.tsx | 296 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project.tsx | 27 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 26 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 14 | ||||
| -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 |
9 files changed, 437 insertions, 458 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 79b9abd33..f402f4bc0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) { const [state, setState] = createStore({ autoselect: !initialDirectory, busyWorkspaces: {} as Record<string, boolean>, - hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, nav: undefined as HTMLElement | undefined, @@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) { onActivate: (directory) => { globalSync.child(directory) setState("hoverProject", directory) - setState("hoverSession", undefined) }, }) @@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) { aim.reset() } const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) - const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const disarm = () => { if (navLeave.current === undefined) return @@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) { const reset = () => { disarm() - setState("hoverSession", undefined) setHoverProject(undefined) } @@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) { navLeave.current = window.setTimeout(() => { navLeave.current = undefined setHoverProject(undefined) - setState("hoverSession", undefined) }, 300) } @@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) { navList: currentSessions, sidebarExpanded, sidebarHovering, - nav: () => state.nav, - hoverSession: () => state.hoverSession, - setHoverSession, clearHoverProjectSoon, prefetchSession, archiveSession, @@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) { sidebarOpened: () => layout.sidebar.opened(), sidebarHovering, hoverProject: () => state.hoverProject, - nav: () => state.nav, onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectFocus: (worktree) => aim.activate(worktree), @@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) { sessionProps: { navList: currentSessions, sidebarExpanded, - sidebarHovering, - nav: () => state.nav, - hoverSession: () => state.hoverSession, - setHoverSession, clearHoverProjectSoon, prefetchSession, archiveSession, }, - setHoverSession, } const SidebarPanel = (panelProps: { @@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) { const project = panelProps.project const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) - const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const projectName = createMemo(() => { const item = project() @@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) { project={project()!} sortNow={sortNow} mobile={panelProps.mobile} - popover={popover()} /> </div> </> @@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) { project={project()!} sortNow={sortNow} mobile={panelProps.mobile} - popover={popover()} /> )} </For> diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 1fe52d47a..988332ab7 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -8,6 +8,7 @@ import { } from "./deep-links" import { type Session } from "@opencode-ai/sdk/v2/client" import { + childSessionOnPath, displayName, effectiveWorkspaceOrder, errorMessage, @@ -198,6 +199,19 @@ describe("layout workspace helpers", () => { expect(result?.id).toBe("root") }) + test("finds the direct child on the active session path", () => { + const list = [ + session({ id: "root", directory: "/workspace" }), + session({ id: "child", directory: "/workspace", parentID: "root" }), + session({ id: "leaf", directory: "/workspace", parentID: "child" }), + ] + + expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child") + expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf") + expect(childSessionOnPath(list, "root", "root")).toBeUndefined() + expect(childSessionOnPath(list, "root", "other")).toBeUndefined() + }) + test("formats fallback project display name", () => { expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 226098c1c..48158debb 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -46,18 +46,17 @@ export function hasProjectPermissions<T>( return Object.values(request ?? {}).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[] | undefined) => { - const map = new Map<string, string[]>() - for (const session of sessions ?? []) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) +export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => { + if (!activeID || activeID === rootID) return + const map = new Map((sessions ?? []).map((session) => [session.id, session])) + let id = activeID + + while (id) { + const session = map.get(id) + if (!session?.parentID) return + if (session.parentID === rootID) return session + id = session.parentID } - return map } export const displayName = (project: { name?: string; worktree: string }) => diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 058bb5a0d..e56accfc8 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,15 +1,12 @@ -import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@opencode-ai/sdk/v2/client" import { Avatar } from "@opencode-ai/ui/avatar" -import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { MessageNav } from "@opencode-ai/ui/message-nav" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" -import { A, useNavigate, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { A, useParams } from "@solidjs/router" +import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" -import { hasProjectPermissions } from "./helpers" +import { childSessionOnPath, hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti ) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return ( <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class="size-full rounded overflow-clip"> @@ -73,13 +71,10 @@ export type SessionItemProps = { slug: string mobile?: boolean dense?: boolean - popover?: boolean - children: Map<string, string[]> + showTooltip?: boolean + showChild?: boolean + level?: number sidebarExpanded: Accessor<boolean> - sidebarHovering: Accessor<boolean> - nav: Accessor<HTMLElement | undefined> - hoverSession: Accessor<string | undefined> - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise<void> @@ -95,116 +90,52 @@ const SessionRow = (props: { hasPermissions: Accessor<boolean> hasError: Accessor<boolean> unseenCount: Accessor<number> - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor<boolean> - warmHover: () => void warmPress: () => void warmFocus: () => void - cancelHoverPrefetch: () => void -}) => { +}): JSX.Element => { const title = () => sessionTitle(props.session.title) return ( <A href={`/${props.slug}/session/${props.session.id}`} - class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} + class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} onPointerDown={props.warmPress} - onPointerEnter={props.warmHover} - onPointerLeave={props.cancelHoverPrefetch} onFocus={props.warmFocus} onClick={() => { - props.setHoverSession(undefined) if (props.sidebarOpened()) return props.clearHoverProjectSoon() }} > - <div - class="shrink-0 size-6 flex items-center justify-center" - style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} - > - <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> - <Match when={props.isWorking()}> - <Spinner class="size-[15px]" /> - </Match> - <Match when={props.hasPermissions()}> - <div class="size-1.5 rounded-full bg-surface-warning-strong" /> - </Match> - <Match when={props.hasError()}> - <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={props.unseenCount() > 0}> - <div class="size-1.5 rounded-full bg-text-interactive-base" /> - </Match> - </Switch> - </div> - <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span> - </A> - ) -} - -const SessionHoverPreview = (props: { - mobile?: boolean - nav: Accessor<HTMLElement | undefined> - hoverSession: Accessor<string | undefined> - session: Session - sidebarHovering: Accessor<boolean> - hoverReady: Accessor<boolean> - hoverMessages: Accessor<UserMessage[] | undefined> - language: ReturnType<typeof useLanguage> - isActive: Accessor<boolean> - slug: string - setHoverSession: (id: string | undefined) => void - messageLabel: (message: Message) => string | undefined - onMessageSelect: (message: Message) => void - trigger: JSX.Element -}): JSX.Element => { - let ref: HTMLDivElement | undefined - - return ( - <HoverCard - openDelay={1000} - closeDelay={props.sidebarHovering() ? 600 : 0} - placement="right-start" - gutter={16} - shift={-2} - trigger={ - <div ref={ref} class="min-w-0 w-full"> - {props.trigger} - </div> - } - open={props.hoverSession() === props.session.id} - onOpenChange={(open) => { - if (!open) { - props.setHoverSession(undefined) - return - } - if (!ref?.matches(":hover")) return - props.setHoverSession(props.session.id) - }} - > - <Show - when={props.hoverReady()} - fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} - > - <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full"> - <MessageNav - messages={props.hoverMessages() ?? []} - current={undefined} - getLabel={props.messageLabel} - onMessageSelect={props.onMessageSelect} - size="normal" - class="w-60" - /> + <Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}> + <div + class="shrink-0 size-6 flex items-center justify-center" + style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} + > + <Switch> + <Match when={props.isWorking()}> + <Spinner class="size-[15px]" /> + </Match> + <Match when={props.hasPermissions()}> + <div class="size-1.5 rounded-full bg-surface-warning-strong" /> + </Match> + <Match when={props.hasError()}> + <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={props.unseenCount() > 0}> + <div class="size-1.5 rounded-full bg-text-interactive-base" /> + </Match> + </Switch> </div> </Show> - </HoverCard> + <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span> + </A> ) } export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() - const navigate = useNavigate() const layout = useLayout() const language = useLanguage() const notification = useNotification() @@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { ) }) - const tint = createMemo(() => { - return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent) + const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) + const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) + const currentChild = createMemo(() => { + if (!props.showChild) return + return childSessionOnPath(sessionStore.session, props.session.id, params.id) }) - const hoverMessages = createMemo(() => - sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), - ) - const hoverReady = createMemo(() => hoverMessages() !== undefined) - const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) - const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) - const isActive = createMemo(() => props.session.id === params.id) - const warm = (span: number, priority: "high" | "low") => { const nav = props.navList?.() const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) @@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { } } - const hoverPrefetch = { - current: undefined as ReturnType<typeof setTimeout> | undefined, - } - const cancelHoverPrefetch = () => { - if (hoverPrefetch.current === undefined) return - clearTimeout(hoverPrefetch.current) - hoverPrefetch.current = undefined - } - const scheduleHoverPrefetch = () => { - warm(1, "high") - if (hoverPrefetch.current !== undefined) return - hoverPrefetch.current = setTimeout(() => { - hoverPrefetch.current = undefined - warm(2, "low") - }, 80) - } - - onCleanup(cancelHoverPrefetch) - - const messageLabel = (message: Message) => { - const parts = sessionStore.part[message.id] ?? [] - const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) - return text?.text - } const item = ( <SessionRow session={props.session} @@ -301,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hasPermissions={hasPermissions} hasError={hasError} unseenCount={unseenCount} - setHoverSession={props.setHoverSession} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} - warmHover={scheduleHoverPrefetch} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} - cancelHoverPrefetch={cancelHoverPrefetch} /> ) return ( - <div - data-session-id={props.session.id} - class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors - hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" - > - <div class="flex min-w-0 items-center gap-1"> - <div class="min-w-0 flex-1"> - <Show - when={hoverEnabled()} - fallback={ - <Tooltip - placement={props.mobile ? "bottom" : "right"} - value={sessionTitle(props.session.title)} - gutter={10} - class="min-w-0 w-full" - > - {item} - </Tooltip> - } - > - <SessionHoverPreview - mobile={props.mobile} - nav={props.nav} - hoverSession={props.hoverSession} - session={props.session} - sidebarHovering={props.sidebarHovering} - hoverReady={hoverReady} - hoverMessages={hoverMessages} - language={language} - isActive={isActive} - slug={props.slug} - setHoverSession={props.setHoverSession} - messageLabel={messageLabel} - onMessageSelect={(message) => { - if (!isActive()) - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) + <> + <div + data-session-id={props.session.id} + class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" + style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} + > + <div class="flex min-w-0 items-center gap-1"> + <div class="min-w-0 flex-1"> + <Show + when={!tooltip()} + fallback={ + <Tooltip + placement={props.mobile ? "bottom" : "right"} + value={sessionTitle(props.session.title)} + gutter={10} + class="min-w-0 w-full" + > + {item} + </Tooltip> + } + > + {item} + </Show> + </div> - navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + <Show when={!props.level}> + <div + class="shrink-0 overflow-hidden transition-[width,opacity]" + classList={{ + "w-6 opacity-100 pointer-events-auto": !!props.mobile, + "w-0 opacity-0 pointer-events-none": !props.mobile, + "group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, + "group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, }} - trigger={item} - /> + > + <Tooltip value={language.t("common.archive")} placement="top"> + <IconButton + icon="archive" + variant="ghost" + class="size-6 rounded-md" + aria-label={language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + </Tooltip> + </div> </Show> </div> - - <div - class="shrink-0 overflow-hidden transition-[width,opacity]" - classList={{ - "w-6 opacity-100 pointer-events-auto": !!props.mobile, - "w-0 opacity-0 pointer-events-none": !props.mobile, - "group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, - "group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, - }} - > - <Tooltip value={language.t("common.archive")} placement="top"> - <IconButton - icon="archive" - variant="ghost" - class="size-6 rounded-md" - aria-label={language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - </Tooltip> - </div> </div> - </div> + <Show when={currentChild()}> + {(child) => ( + <div class="w-full"> + <SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} /> + </div> + )} + </Show> + </> ) } @@ -390,7 +280,6 @@ export const NewSessionItem = (props: { dense?: boolean sidebarExpanded: Accessor<boolean> clearHoverProjectSoon: () => void - setHoverSession: (id: string | undefined) => void }): JSX.Element => { const layout = useLayout() const language = useLanguage() @@ -400,9 +289,8 @@ export const NewSessionItem = (props: { <A href={`/${props.slug}/session`} end - class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} + class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} onClick={() => { - props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index aff0645dd..7c9ae1aaf 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { base64Encode } from "@opencode-ai/util/encode" import { Button } from "@opencode-ai/ui/button" @@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" -import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { displayName, sortedRootSessions } from "./helpers" export type ProjectSidebarContext = { currentDir: Accessor<string> @@ -19,7 +19,6 @@ export type ProjectSidebarContext = { sidebarOpened: Accessor<boolean> sidebarHovering: Accessor<boolean> hoverProject: Accessor<string | undefined> - nav: Accessor<HTMLElement | undefined> onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void @@ -32,8 +31,7 @@ export type ProjectSidebarContext = { workspacesEnabled: (project: LocalProject) => boolean workspaceIds: (project: LocalProject) => string[] workspaceLabel: (directory: string, branch?: string, projectId?: string) => string - sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover"> - setHoverSession: (id: string | undefined) => void + sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense"> } export const ProjectDragOverlay = (props: { @@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: { const ProjectTile = (props: { project: LocalProject mobile?: boolean - nav: Accessor<HTMLElement | undefined> sidebarHovering: Accessor<boolean> selected: Accessor<boolean> active: Accessor<boolean> @@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: { workspaces: Accessor<string[]> label: (directory: string) => string projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> - projectChildren: Accessor<Map<string, string[]>> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> - workspaceChildren: (directory: string) => Map<string, string[]> ctx: ProjectSidebarContext language: ReturnType<typeof useLanguage> }): JSX.Element => ( @@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: { list={props.projectSessions()} slug={base64Encode(props.project.worktree)} dense + showTooltip mobile={props.mobile} - popover={false} - children={props.projectChildren()} /> )} </For> @@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: { <For each={props.workspaces()}> {(directory) => { const sessions = createMemo(() => props.workspaceSessions(directory)) - const children = createMemo(() => props.workspaceChildren(directory)) return ( <div class="flex flex-col gap-1"> <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> @@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: { list={sessions()} slug={base64Encode(directory)} dense + showTooltip mobile={props.mobile} - popover={false} - children={children()} /> )} </For> @@ -310,20 +302,14 @@ export const SortableProject = (props: { const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) - const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) return sortedRootSessions(data, props.sortNow()) } - const workspaceChildren = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - return childMapByParent(data.session) - } const tile = () => ( <ProjectTile project={props.project} mobile={props.mobile} - nav={props.ctx.nav} sidebarHovering={props.ctx.sidebarHovering} selected={selected} active={active} @@ -360,7 +346,6 @@ export const SortableProject = (props: { if (state.menu) return if (value && state.suppressHover) return props.ctx.onHoverOpenChanged(props.project.worktree, value) - if (value) props.ctx.setHoverSession(undefined) }} > <ProjectPreviewPanel @@ -371,9 +356,7 @@ export const SortableProject = (props: { workspaces={workspaces} label={label} projectSessions={projectSessions} - projectChildren={projectChildren} workspaceSessions={workspaceSessions} - workspaceChildren={workspaceChildren} ctx={props.ctx} language={language} /> diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 3bf00ea42..68e36ff77 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers" +import { sortedRootSessions, workspaceKey } from "./helpers" type InlineEditorComponent = (props: { id: string @@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = { navList: Accessor<Session[]> sidebarExpanded: Accessor<boolean> sidebarHovering: Accessor<boolean> - nav: Accessor<HTMLElement | undefined> - hoverSession: Accessor<string | undefined> - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise<void> @@ -152,7 +149,6 @@ const WorkspaceActions = (props: { showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] root: string - setHoverSession: WorkspaceSidebarContext["setHoverSession"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] navigateToNewSession: () => void }): JSX.Element => ( @@ -226,7 +222,6 @@ const WorkspaceActions = (props: { onClick={(event) => { event.preventDefault() event.stopPropagation() - props.setHoverSession(undefined) props.clearHoverProjectSoon() props.navigateToNewSession() }} @@ -239,12 +234,10 @@ const WorkspaceActions = (props: { const WorkspaceSessionList = (props: { slug: Accessor<string> mobile?: boolean - popover?: boolean ctx: WorkspaceSidebarContext showNew: Accessor<boolean> loading: Accessor<boolean> sessions: Accessor<Session[]> - children: Accessor<Map<string, string[]>> hasMore: Accessor<boolean> loadMore: () => Promise<void> language: ReturnType<typeof useLanguage> @@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: { mobile={props.mobile} sidebarExpanded={props.ctx.sidebarExpanded} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - setHoverSession={props.ctx.setHoverSession} /> </Show> <Show when={props.loading()}> @@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: { navList={props.ctx.navList} slug={props.slug()} mobile={props.mobile} - popover={props.popover} - children={props.children()} + showChild sidebarExpanded={props.ctx.sidebarExpanded} - sidebarHovering={props.ctx.sidebarHovering} - nav={props.ctx.nav} - hoverSession={props.ctx.hoverSession} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} @@ -307,7 +294,6 @@ export const SortableWorkspace = (props: { project: LocalProject sortNow: Accessor<number> mobile?: boolean - popover?: boolean }): JSX.Element => { const navigate = useNavigate() const params = useParams() @@ -321,7 +307,6 @@ export const SortableWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) - const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -428,7 +413,6 @@ export const SortableWorkspace = (props: { showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} root={props.project.worktree} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} navigateToNewSession={() => navigate(`/${slug()}/session`)} /> @@ -440,12 +424,10 @@ export const SortableWorkspace = (props: { <WorkspaceSessionList slug={slug} mobile={props.mobile} - popover={props.popover} ctx={props.ctx} showNew={showNew} loading={loading} sessions={sessions} - children={children} hasMore={hasMore} loadMore={loadMore} language={language} @@ -461,7 +443,6 @@ export const LocalWorkspace = (props: { project: LocalProject sortNow: Accessor<number> mobile?: boolean - popover?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() @@ -471,7 +452,6 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) @@ -489,12 +469,10 @@ export const LocalWorkspace = (props: { <WorkspaceSessionList slug={slug} mobile={props.mobile} - popover={props.popover} ctx={props.ctx} showNew={() => false} loading={loading} sessions={sessions} - children={children} hasMore={hasMore} loadMore={loadMore} language={language} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a81df9dd2..0c6764726 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -429,6 +429,7 @@ export default function Page() { } const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasSessionReview = createMemo(() => sessionCount() > 0) @@ -1058,7 +1059,7 @@ export default function Page() { } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - if (composer.blocked()) return + if (composer.blocked() || isChildSession()) return inputRef?.focus() } } @@ -1127,7 +1128,10 @@ export default function Page() { setFileTreeTab("all") } - const focusInput = () => inputRef?.focus() + const focusInput = () => { + if (isChildSession()) return + inputRef?.focus() + } useSessionCommands({ navigateMessageByOffset, @@ -1658,7 +1662,7 @@ export default function Page() { const queueEnabled = createMemo(() => { const id = params.id if (!id) return false - return settings.general.followup() === "queue" && busy(id) && !composer.blocked() + return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() }) const followupText = (item: FollowupDraft) => { @@ -1690,6 +1694,7 @@ export default function Page() { const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { + if (sync.session.get(sessionID)?.parentID) return Promise.resolve() const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() @@ -1820,6 +1825,7 @@ export default function Page() { if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return if (followup.paused[sessionID]) return + if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return @@ -2001,7 +2007,7 @@ export default function Page() { }} onResponseSubmit={resumeScroll} followup={ - params.id + params.id && !isChildSession() ? { queue: queueEnabled, items: followupDock(), 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> |
