diff options
| author | Frank <[email protected]> | 2026-03-04 11:13:14 -0500 |
|---|---|---|
| committer | Frank <[email protected]> | 2026-03-04 11:13:14 -0500 |
| commit | e9de2505f65b65a581a90dbd3b29f21dac6568de (patch) | |
| tree | b4f982f67f5e1fd78debf192ad237806db2cd62d /packages/app/src | |
| parent | 22fcde926fd8a071a213a54d624584372e73822d (diff) | |
| parent | 715b844c2a88810b6178d7a2467c7d36ea8fb764 (diff) | |
| download | opencode-e9de2505f65b65a581a90dbd3b29f21dac6568de.tar.gz opencode-e9de2505f65b65a581a90dbd3b29f21dac6568de.zip | |
Merge branch 'dev' into go-page
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/app.tsx | 11 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 45 | ||||
| -rw-r--r-- | packages/app/src/components/titlebar.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-items.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-region.tsx | 43 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-todo-dock.tsx | 7 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 419 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/utils/notification-click.test.ts | 37 | ||||
| -rw-r--r-- | packages/app/src/utils/notification-click.ts | 16 |
12 files changed, 323 insertions, 278 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 4a25e8d94..52a1dac6a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { Font } from "@opencode-ai/ui/font" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" -import { Navigate, Route, Router } from "@solidjs/router" -import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" +import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" +import { Dynamic } from "solid-js/web" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -144,13 +145,15 @@ export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key servers?: Array<ServerConnection.Any> + router?: Component<BaseRouterProps> }) { return ( <ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerKey> <GlobalSDKProvider> <GlobalSyncProvider> - <Router + <Dynamic + component={props.router ?? Router} root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} > <Route path="/" component={HomeRoute} /> @@ -158,7 +161,7 @@ export function AppInterface(props: { <Route path="/" component={SessionIndexRoute} /> <Route path="/session/:id?" component={SessionRoute} /> </Route> - </Router> + </Dynamic> </GlobalSyncProvider> </GlobalSDKProvider> </ServerKey> diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 601ace28d..c27d6a977 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY onSubmit?: () => void - onCleanup?: (pty: LocalPTY) => void + onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onConnect?: () => void onConnectError?: (error: unknown) => void } @@ -126,8 +126,8 @@ const persistTerminal = (input: { term: Term | undefined addon: SerializeAddon | undefined cursor: number - pty: LocalPTY - onCleanup?: (pty: LocalPTY) => void + id: string + onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void }) => { if (!input.addon || !input.onCleanup || !input.term) return const buffer = (() => { @@ -140,7 +140,7 @@ const persistTerminal = (input: { })() input.onCleanup({ - ...input.pty, + id: input.id, buffer, cursor: input.cursor, rows: input.term.rows, @@ -158,6 +158,19 @@ export const Terminal = (props: TerminalProps) => { const server = useServer() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) + const id = local.pty.id + const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" + const restoreSize = + restore && + typeof local.pty.cols === "number" && + Number.isSafeInteger(local.pty.cols) && + local.pty.cols > 0 && + typeof local.pty.rows === "number" && + Number.isSafeInteger(local.pty.rows) && + local.pty.rows > 0 + ? { cols: local.pty.cols, rows: local.pty.rows } + : undefined + const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined let ws: WebSocket | undefined let term: Term | undefined let ghostty: Ghostty @@ -190,7 +203,7 @@ export const Terminal = (props: TerminalProps) => { const pushSize = (cols: number, rows: number) => { return sdk.client.pty .update({ - ptyID: local.pty.id, + ptyID: id, size: { cols, rows }, }) .catch((err) => { @@ -319,18 +332,6 @@ export const Terminal = (props: TerminalProps) => { const mod = loaded.mod const g = loaded.ghostty - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" - const restoreSize = - restore && - typeof local.pty.cols === "number" && - Number.isSafeInteger(local.pty.cols) && - local.pty.cols > 0 && - typeof local.pty.rows === "number" && - Number.isSafeInteger(local.pty.rows) && - local.pty.rows > 0 - ? { cols: local.pty.cols, rows: local.pty.rows } - : undefined - const t = new mod.Terminal({ cursorBlink: true, cursorStyle: "bar", @@ -427,14 +428,14 @@ export const Terminal = (props: TerminalProps) => { await write(restore) fit.fit() scheduleSize(t.cols, t.rows) - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + if (scrollY !== undefined) t.scrollToLine(scrollY) startResize() } else { fit.fit() scheduleSize(t.cols, t.rows) if (restore) { await write(restore) - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + if (scrollY !== undefined) t.scrollToLine(scrollY) } startResize() } @@ -446,9 +447,9 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } let closing = false - const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) + const url = new URL(sdk.url + `/pty/${id}/connect`) url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) + url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.username = server.current?.http.username ?? "" url.password = server.current?.http.password ?? "" @@ -542,7 +543,7 @@ export const Terminal = (props: TerminalProps) => { if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) const finalize = () => { - persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) + persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) cleanup() } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 760f40fc0..c2b5a1ef4 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -157,6 +157,7 @@ export function Titlebar() { <header class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center" style={{ "min-height": minHeight() }} + data-tauri-drag-region onMouseDown={drag} onDblClick={maximize} > @@ -276,6 +277,7 @@ export function Titlebar() { "flex items-center min-w-0 justify-end": true, "pr-2": !windows(), }} + data-tauri-drag-region onMouseDown={drag} > <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index daf2aaa5c..2fd2f2fe3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -42,6 +42,7 @@ import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" import { createAim } from "@/utils/aim" +import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -107,6 +108,7 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const permission = usePermission() const navigate = useNavigate() + setNavigate(navigate) const providers = useProviders() const dialog = useDialog() const command = useCommand() diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index eecfd17b5..0aaabc03b 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -6,7 +6,6 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { base64Encode } from "@opencode-ai/util/encode" import { Avatar } from "@opencode-ai/ui/avatar" -import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -137,13 +136,6 @@ const SessionRow = (props: { <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> {props.session.title} </span> - <Show when={props.session.summary}> - {(summary) => ( - <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <DiffChanges changes={summary()} /> - </div> - )} - </Show> </div> </A> ) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 389c0baea..cc81ae7b6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1254,6 +1254,7 @@ export default function Page() { <SessionComposerRegion state={composer} + ready={!store.deferRender && messagesReady()} centered={centered()} inputRef={(el) => { inputRef = el 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 a882e25dd..93ea3d465 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,4 +1,5 @@ import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" @@ -12,6 +13,7 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" export function SessionComposerRegion(props: { state: SessionComposerState + ready: boolean centered: boolean inputRef: (el: HTMLDivElement) => void newSessionWorktree: string @@ -61,7 +63,44 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const open = createMemo(() => props.state.dock() && !props.state.closing()) + const [gate, setGate] = createStore({ + ready: false, + }) + let timer: number | undefined + let frame: number | undefined + + const clear = () => { + if (timer !== undefined) { + window.clearTimeout(timer) + timer = undefined + } + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + } + + createEffect(() => { + sessionKey() + const ready = props.ready + const delay = 140 + + clear() + setGate("ready", false) + if (!ready) return + + frame = requestAnimationFrame(() => { + frame = undefined + timer = window.setTimeout(() => { + setGate("ready", true) + timer = undefined + }, delay) + }) + }) + + onCleanup(clear) + + const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing()) const config = createMemo(() => open() ? { @@ -76,7 +115,7 @@ export function SessionComposerRegion(props: { const progress = useSpring(() => (open() ? 1 : 0), config) const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const [height, setHeight] = createSignal(320) - const dock = createMemo(() => props.state.dock() || value() > 0.001) + const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) const full = createMemo(() => Math.max(78, height())) const [contentRef, setContentRef] = createSignal<HTMLDivElement>() diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index ab8755aec..da2b8c8da 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -281,10 +281,8 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px", - transition: - "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", opacity: todo().status === "pending" ? "0.94" : "1", - filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", }} > <TextStrikethrough @@ -294,13 +292,12 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { style={{ "line-height": "var(--line-height-normal)", transition: - "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", color: todo().status === "completed" || todo().status === "cancelled" ? "var(--text-weak)" : "var(--text-strong)", opacity: todo().status === "pending" ? "0.92" : "1", - filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", }} /> </Checkbox> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a7f503d5a..433c36e2e 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -550,227 +550,228 @@ export function MessageTimeline(props: { "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > - <Show when={showHeader()}> - <div - data-session-title - classList={{ - "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": 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, - }} - > - <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> - <Show when={titleValue() || title.editing}> - <Show - when={title.editing} - fallback={ - <h1 - class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" - onDblClick={openTitleEditor} - > - {titleValue()} - </h1> - } - > - <InlineInput - ref={(el) => { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} + <div ref={props.setContentRef} class="min-w-0 w-full"> + <Show when={showHeader()}> + <div + data-session-title + classList={{ + "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": 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, + }} + > + <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> - </Show> - </div> - <Show when={sessionID()}> - {(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)} + <Show when={titleValue() || title.editing}> + <Show + when={title.editing} + fallback={ + <h1 + class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" + onDblClick={openTitleEditor} + > + {titleValue()} + </h1> + } > - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - aria-label={language.t("common.moreOptions")} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content - style={{ "min-width": "104px" }} - onCloseAutoFocus={(event) => { - if (!title.pendingRename) return + <InlineInput + ref={(el) => { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - <DropdownMenu.Item - onSelect={() => { - setTitle("pendingRename", true) - setTitle("menuOpen", false) + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + </Show> + </Show> + </div> + <Show when={sessionID()}> + {(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)} + > + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md data-[expanded]:bg-surface-base-active" + aria-label={language.t("common.moreOptions")} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content + style={{ "min-width": "104px" }} + onCloseAutoFocus={(event) => { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() }} > - <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <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> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - )} - </Show> - </div> - </div> - </Show> - - <div - ref={props.setContentRef} - role="log" - class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" - classList={{ - "w-full": true, - "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, - "mt-0.5": props.centered, - "mt-0": !props.centered, - }} - > - <Show when={props.turnStart > 0 || props.historyMore}> - <div class="w-full flex justify-center"> - <Button - variant="ghost" - size="large" - class="text-12-medium opacity-50" - disabled={props.historyLoading} - onClick={props.onLoadEarlier} - > - {props.historyLoading - ? language.t("session.messages.loadingEarlier") - : language.t("session.messages.loadEarlier")} - </Button> + <DropdownMenu.Item + onSelect={() => { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <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> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + )} + </Show> + </div> </div> </Show> - <For each={rendered()}> - {(messageID) => { - const active = createMemo(() => activeMessageID() === messageID) - const queued = createMemo(() => { - if (active()) return false - const activeID = activeMessageID() - if (activeID) return messageID > activeID - return false - }) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), - }) - const commentCount = createMemo(() => comments().length) - return ( - <div - id={props.anchor(messageID)} - data-message-id={messageID} - ref={(el) => { - props.onRegisterMessage(el, messageID) - onCleanup(() => props.onUnregisterMessage(messageID)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - }} + + <div + role="log" + class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" + classList={{ + "w-full": true, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, + "mt-0.5": props.centered, + "mt-0": !props.centered, + }} + > + <Show when={props.turnStart > 0 || props.historyMore}> + <div class="w-full flex justify-center"> + <Button + variant="ghost" + size="large" + class="text-12-medium opacity-50" + disabled={props.historyLoading} + onClick={props.onLoadEarlier} > - <Show when={commentCount() > 0}> - <div class="w-full px-4 md:px-5 pb-2"> - <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar"> - <div class="flex w-max min-w-full justify-end gap-2"> - <Index each={comments()}> - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> - <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> - <FileIcon - node={{ path: comment().path, type: "file" }} - class="size-3.5 shrink-0" - /> - <span class="truncate">{getFilename(comment().path)}</span> - <Show when={comment().selection}> - {(selection) => ( - <span class="shrink-0 text-text-weak"> - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - </span> - )} - </Show> - </div> - <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> - {comment().comment} + {props.historyLoading + ? language.t("session.messages.loadingEarlier") + : language.t("session.messages.loadEarlier")} + </Button> + </div> + </Show> + <For each={rendered()}> + {(messageID) => { + const active = createMemo(() => activeMessageID() === messageID) + const queued = createMemo(() => { + if (active()) return false + const activeID = activeMessageID() + if (activeID) return messageID > activeID + return false + }) + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { + equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + }) + const commentCount = createMemo(() => comments().length) + return ( + <div + id={props.anchor(messageID)} + data-message-id={messageID} + ref={(el) => { + props.onRegisterMessage(el, messageID) + onCleanup(() => props.onUnregisterMessage(messageID)) + }} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, + }} + > + <Show when={commentCount() > 0}> + <div class="w-full px-4 md:px-5 pb-2"> + <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar"> + <div class="flex w-max min-w-full justify-end gap-2"> + <Index each={comments()}> + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( + <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> + <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> + <FileIcon + node={{ path: comment().path, type: "file" }} + class="size-3.5 shrink-0" + /> + <span class="truncate">{getFilename(comment().path)}</span> + <Show when={comment().selection}> + {(selection) => ( + <span class="shrink-0 text-text-weak"> + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + </span> + )} + </Show> + </div> + <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> + {comment().comment} + </div> </div> - </div> - ) - }} - </Index> + ) + }} + </Index> + </div> </div> </div> - </div> - </Show> - <SessionTurn - sessionID={sessionID() ?? ""} - messageID={messageID} - active={active()} - queued={queued()} - status={active() ? sessionStatus() : undefined} - showReasoningSummaries={settings.general.showReasoningSummaries()} - shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} - editToolDefaultOpen={settings.general.editToolPartsExpanded()} - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-5", - }} - /> - </div> - ) - }} - </For> + </Show> + <SessionTurn + sessionID={sessionID() ?? ""} + messageID={messageID} + active={active()} + queued={queued()} + status={active() ? sessionStatus() : undefined} + showReasoningSummaries={settings.general.showReasoningSummaries()} + shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} + editToolDefaultOpen={settings.general.editToolPartsExpanded()} + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-5", + }} + /> + </div> + ) + }} + </For> + </div> </div> </ScrollView> </div> diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 49bed9490..cc4c17ee2 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -102,7 +102,7 @@ export function TerminalPanel() { const all = createMemo(() => terminal.all()) const ids = createMemo(() => all().map((pty) => pty.id)) - const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty]))) + const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }]))) const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -189,7 +189,13 @@ export function TerminalPanel() { > <Tabs.List class="h-10"> <SortableProvider ids={ids()}> - <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For> + <For each={ids()}> + {(id) => ( + <Show when={byId().get(id)}> + {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />} + </Show> + )} + </For> </SortableProvider> <div class="h-full flex items-center justify-center"> <TooltipKeybind diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts index 76535f83a..fa81b0e02 100644 --- a/packages/app/src/utils/notification-click.test.ts +++ b/packages/app/src/utils/notification-click.test.ts @@ -1,26 +1,27 @@ -import { describe, expect, test } from "bun:test" -import { handleNotificationClick } from "./notification-click" +import { afterEach, describe, expect, test } from "bun:test" +import { handleNotificationClick, setNavigate } from "./notification-click" describe("notification click", () => { - test("focuses and navigates when href exists", () => { + afterEach(() => { + setNavigate(undefined as any) + }) + + test("navigates via registered navigate function", () => { const calls: string[] = [] - handleNotificationClick("/abc/session/123", { - focus: () => calls.push("focus"), - location: { - assign: (href) => calls.push(href), - }, - }) - expect(calls).toEqual(["focus", "/abc/session/123"]) + setNavigate((href) => calls.push(href)) + handleNotificationClick("/abc/session/123") + expect(calls).toEqual(["/abc/session/123"]) }) - test("only focuses when href is missing", () => { + test("does not navigate when href is missing", () => { const calls: string[] = [] - handleNotificationClick(undefined, { - focus: () => calls.push("focus"), - location: { - assign: (href) => calls.push(href), - }, - }) - expect(calls).toEqual(["focus"]) + setNavigate((href) => calls.push(href)) + handleNotificationClick(undefined) + expect(calls).toEqual([]) + }) + + test("falls back to location.assign without registered navigate", () => { + handleNotificationClick("/abc/session/123") + // falls back to window.location.assign — no error thrown }) }) diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts index 1234cd1d6..94086c595 100644 --- a/packages/app/src/utils/notification-click.ts +++ b/packages/app/src/utils/notification-click.ts @@ -1,12 +1,12 @@ -type WindowTarget = { - focus: () => void - location: { - assign: (href: string) => void - } +let nav: ((href: string) => void) | undefined + +export const setNavigate = (fn: (href: string) => void) => { + nav = fn } -export const handleNotificationClick = (href?: string, target: WindowTarget = window) => { - target.focus() +export const handleNotificationClick = (href?: string) => { + window.focus() if (!href) return - target.location.assign(href) + if (nav) nav(href) + else window.location.assign(href) } |
