summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-03-04 11:13:14 -0500
committerFrank <[email protected]>2026-03-04 11:13:14 -0500
commite9de2505f65b65a581a90dbd3b29f21dac6568de (patch)
treeb4f982f67f5e1fd78debf192ad237806db2cd62d /packages/app/src
parent22fcde926fd8a071a213a54d624584372e73822d (diff)
parent715b844c2a88810b6178d7a2467c7d36ea8fb764 (diff)
downloadopencode-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.tsx11
-rw-r--r--packages/app/src/components/terminal.tsx45
-rw-r--r--packages/app/src/components/titlebar.tsx2
-rw-r--r--packages/app/src/pages/layout.tsx2
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx8
-rw-r--r--packages/app/src/pages/session.tsx1
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx43
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx7
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx419
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx10
-rw-r--r--packages/app/src/utils/notification-click.test.ts37
-rw-r--r--packages/app/src/utils/notification-click.ts16
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)
}