props.openComment(item)}
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts
index b633df829..5f6aa59e9 100644
--- a/packages/app/src/components/prompt-input/placeholder.test.ts
+++ b/packages/app/src/components/prompt-input/placeholder.test.ts
@@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
mode: "shell",
commentCount: 0,
example: "example",
+ suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell")
})
test("returns summarize placeholders for comment context", () => {
- expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+ expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComment",
)
- expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+ expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComments",
)
})
- test("returns default placeholder with example", () => {
+ test("returns default placeholder with example when suggestions enabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
+ suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.normal:translated-example")
})
+
+ test("returns simple placeholder when suggestions disabled", () => {
+ const value = promptPlaceholder({
+ mode: "normal",
+ commentCount: 0,
+ example: "translated-example",
+ suggest: false,
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.simple")
+ })
})
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
index 07f6a43b5..395fee51b 100644
--- a/packages/app/src/components/prompt-input/placeholder.ts
+++ b/packages/app/src/components/prompt-input/placeholder.ts
@@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
mode: "normal" | "shell"
commentCount: number
example: string
+ suggest: boolean
t: (key: string, params?: Record
) => string
}
@@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+ if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx
index 259883d61..65eb01c79 100644
--- a/packages/app/src/components/prompt-input/slash-popover.tsx
+++ b/packages/app/src/components/prompt-input/slash-popover.tsx
@@ -40,9 +40,9 @@ export const PromptPopover: Component = (props) => {
ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
- class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
- overflow-auto no-scrollbar flex flex-col p-2 rounded-md
- border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+ class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
+ bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
onMouseDown={(e) => e.preventDefault()}
>
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 475a0e20f..c3d6a9281 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
-const clientFor = (directory: string) => ({
- session: {
- create: async () => {
- createdSessions.push(directory)
- return { data: { id: `session-${createdSessions.length}` } }
+const clientFor = (directory: string) => {
+ createdClients.push(directory)
+ return {
+ session: {
+ create: async () => {
+ createdSessions.push(directory)
+ return { data: { id: `session-${createdSessions.length}` } }
+ },
+ shell: async () => {
+ sentShell.push(directory)
+ return { data: undefined }
+ },
+ prompt: async () => ({ data: undefined }),
+ command: async () => ({ data: undefined }),
+ abort: async () => ({ data: undefined }),
},
- shell: async () => {
- sentShell.push(directory)
- return { data: undefined }
+ worktree: {
+ create: async () => ({ data: { directory: `${directory}/new` } }),
},
- prompt: async () => ({ data: undefined }),
- command: async () => ({ data: undefined }),
- abort: async () => ({ data: undefined }),
- },
- worktree: {
- create: async () => ({ data: { directory: `${directory}/new` } }),
- },
-})
+ }
+}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
}))
mock.module("@/context/sdk", () => ({
- useSDK: () => ({
- directory: "/repo/main",
- client: rootClient,
- url: "http://localhost:4096",
- }),
+ useSDK: () => {
+ const sdk = {
+ directory: "/repo/main",
+ client: rootClient,
+ url: "http://localhost:4096",
+ createClient(opts: any) {
+ return clientFor(opts.directory)
+ },
+ }
+ return sdk
+ },
}))
mock.module("@/context/sync", () => ({
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 9a1fba5d5..8a3dfc40d 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -1,21 +1,20 @@
-import { Accessor } from "solid-js"
-import { useNavigate, useParams } from "@solidjs/router"
-import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
+import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
-import { useLocal } from "@/context/local"
-import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
+import { useNavigate, useParams } from "@solidjs/router"
+import type { Accessor } from "solid-js"
+import type { FileSelection } from "@/context/file"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
-import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
-import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
-import type { FileSelection } from "@/context/file"
-import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
+import { setCursorPosition } from "./editor-dom"
type PendingPrompt = {
abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
- const platform = usePlatform()
const local = useLocal()
const prompt = usePrompt()
const layout = useLayout()
@@ -80,6 +78,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
+ globalSync.todo.set(sessionID, undefined)
return Promise.resolve()
}
return sdk.client.session
@@ -87,6 +86,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID,
})
.catch(() => {})
+ .finally(() => {
+ globalSync.todo.set(sessionID, undefined)
+ })
}
const restoreCommentItems = (items: CommentItem[]) => {
@@ -171,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
- client = createOpencodeClient({
- baseUrl: sdk.url,
- fetch: platform.fetch,
+ client = sdk.createClient({
directory: sessionDirectory,
throwOnError: true,
})
@@ -368,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined }
const timeout = new Promise>>((resolve) => {
timer.id = window.setTimeout(() => {
- resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+ resolve({
+ status: "failed",
+ message: language.t("workspace.error.stillPreparing"),
+ })
}, timeoutMs)
})
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx
index 5054253b8..cd2e495b1 100644
--- a/packages/app/src/components/question-dock.tsx
+++ b/packages/app/src/components/question-dock.tsx
@@ -1,6 +1,7 @@
-import { For, Show, createMemo, type Component } from "solid-js"
+import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -12,25 +13,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const language = useLanguage()
const questions = createMemo(() => props.request.questions)
- const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+ const total = createMemo(() => questions().length)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
+ customOn: [] as boolean[],
editing: false,
sending: false,
})
+ let root: HTMLDivElement | undefined
+
const question = createMemo(() => questions()[store.tab])
- const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
+ const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
- const customPicked = createMemo(() => {
- const value = input()
- if (!value) return false
- return store.answers[store.tab]?.includes(value) ?? false
+
+ const summary = createMemo(() => {
+ const n = Math.min(store.tab + 1, total())
+ return `${n} of ${total()} questions`
+ })
+
+ const last = createMemo(() => store.tab >= total() - 1)
+
+ const customUpdate = (value: string, selected: boolean = on()) => {
+ const prev = input().trim()
+ const next = value.trim()
+
+ setStore("custom", store.tab, value)
+ if (!selected) return
+
+ if (multi()) {
+ setStore("answers", store.tab, (current = []) => {
+ const removed = prev ? current.filter((item) => item.trim() !== prev) : current
+ if (!next) return removed
+ if (removed.some((item) => item.trim() === next)) return removed
+ return [...removed, next]
+ })
+ return
+ }
+
+ setStore("answers", store.tab, next ? [next] : [])
+ }
+
+ const measure = () => {
+ if (!root) return
+
+ const scroller = document.querySelector(".session-scroller")
+ const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
+ const top =
+ head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
+ if (!top) {
+ root.style.removeProperty("--question-prompt-max-height")
+ return
+ }
+
+ const dock = root.closest('[data-component="session-prompt-dock"]')
+ if (!(dock instanceof HTMLElement)) return
+
+ const dockBottom = dock.getBoundingClientRect().bottom
+ const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
+ const gap = 8
+ const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
+ root.style.setProperty("--question-prompt-max-height", `${max}px`)
+ }
+
+ onMount(() => {
+ let raf: number | undefined
+ const update = () => {
+ if (raf !== undefined) cancelAnimationFrame(raf)
+ raf = requestAnimationFrame(() => {
+ raf = undefined
+ measure()
+ })
+ }
+
+ update()
+ window.addEventListener("resize", update)
+
+ const dock = root?.closest('[data-component="session-prompt-dock"]')
+ const scroller = document.querySelector(".session-scroller")
+ const observer = new ResizeObserver(update)
+ if (dock instanceof HTMLElement) observer.observe(dock)
+ if (scroller instanceof HTMLElement) observer.observe(scroller)
+
+ onCleanup(() => {
+ window.removeEventListener("resize", update)
+ observer.disconnect()
+ if (raf !== undefined) cancelAnimationFrame(raf)
+ })
})
const fail = (err: unknown) => {
@@ -64,23 +138,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
}
- const submit = () => {
- void reply(questions().map((_, i) => store.answers[i] ?? []))
- }
+ const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
-
- if (custom) {
- setStore("custom", store.tab, answer)
- }
-
- if (single()) {
- void reply([[answer]])
- return
- }
-
- setStore("tab", store.tab + 1)
+ if (custom) setStore("custom", store.tab, answer)
+ if (!custom) setStore("customOn", store.tab, false)
+ setStore("editing", false)
}
const toggle = (answer: string) => {
@@ -90,16 +154,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
})
}
- const selectTab = (index: number) => {
- setStore("tab", index)
+ const customToggle = () => {
+ if (store.sending) return
+
+ if (!multi()) {
+ setStore("customOn", store.tab, true)
+ setStore("editing", true)
+ customUpdate(input(), true)
+ return
+ }
+
+ const next = !on()
+ setStore("customOn", store.tab, next)
+ if (next) {
+ setStore("editing", true)
+ customUpdate(input(), true)
+ return
+ }
+
+ const value = input().trim()
+ if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false)
}
+ const customOpen = () => {
+ if (store.sending) return
+ if (!on()) setStore("customOn", store.tab, true)
+ setStore("editing", true)
+ customUpdate(input(), true)
+ }
+
const selectOption = (optIndex: number) => {
if (store.sending) return
if (optIndex === options().length) {
- setStore("editing", true)
+ customOpen()
return
}
@@ -112,176 +201,225 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
pick(opt.label)
}
- const handleCustomSubmit = (e: Event) => {
- e.preventDefault()
+ const commitCustom = () => {
+ setStore("editing", false)
+ customUpdate(input())
+ }
+
+ const next = () => {
if (store.sending) return
+ if (store.editing) commitCustom()
- const value = input().trim()
- if (!value) {
- setStore("editing", false)
+ if (store.tab >= total() - 1) {
+ submit()
return
}
- if (multi()) {
- setStore("answers", store.tab, (current = []) => {
- if (current.includes(value)) return current
- return [...current, value]
- })
- setStore("editing", false)
- return
- }
+ setStore("tab", store.tab + 1)
+ setStore("editing", false)
+ }
- pick(value, true)
+ const back = () => {
+ if (store.sending) return
+ if (store.tab <= 0) return
+ setStore("tab", store.tab - 1)
+ setStore("editing", false)
+ }
+
+ const jump = (tab: number) => {
+ if (store.sending) return
+ setStore("tab", tab)
setStore("editing", false)
}
return (
-
-
-
-
- {(q, index) => {
- const active = () => index() === store.tab
- const answered = () => (store.answers[index()]?.length ?? 0) > 0
- return (
+ (root = el)}
+ header={
+ <>
+ {summary()}
+
+
+ {(_, i) => (
- )
- }}
-
-
-
-
-
-
-
-
- {question()?.question}
- {multi() ? " " + language.t("ui.question.multiHint") : ""}
-
-
-
- {(opt, i) => {
- const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
- return (
-
- )
- }}
+ onClick={() => jump(i())}
+ aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
+ />
+ )}
+
+ >
+ }
+ footer={
+ <>
+
+
+ 0}>
+
+
+
+
+ >
+ }
+ >
+
{question()?.question}
+
{language.t("ui.question.singleHint")} }>
+ {language.t("ui.question.multiHint")}
+
+
+
+ {(opt, i) => {
+ const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+ return (
+
+ )
+ }}
+
+
+ selectOption(options().length)}
+ onClick={customOpen}
>
- {language.t("ui.messagePart.option.typeOwnAnswer")}
-
- {input()}
-
-
-
-
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ customToggle()
+ }}
+ >
+
+ }>
+
+
+
+
+
+ {language.t("ui.messagePart.option.typeOwnAnswer")}
+ {input() || language.t("ui.question.custom.placeholder")}
+
-
-
-
-
-
-
-
-
-
-
{language.t("ui.messagePart.review.title")}
-
- {(q, index) => {
- const value = () => store.answers[index()]?.join(", ") ?? ""
- const answered = () => Boolean(value())
- return (
-
- {q.question}
-
- {answered() ? value() : language.t("ui.question.review.notAnswered")}
-
-
- )
+ }
+ >
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+ }>
+
+
+
+
+
+ {language.t("ui.messagePart.option.typeOwnAnswer")}
+
+
-
+
)
}
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx
index f93bdb33b..12dcebfa9 100644
--- a/packages/app/src/components/server/server-row.tsx
+++ b/packages/app/src/components/server/server-row.tsx
@@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
-import { serverDisplayName } from "@/context/server"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ type JSXElement,
+ onCleanup,
+ onMount,
+ type ParentProps,
+ Show,
+} from "solid-js"
+import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
- url: string
+ conn: ServerConnection.Any
status?: ServerHealth
class?: string
nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
- const name = createMemo(() => serverDisplayName(props.url))
+ const name = createMemo(() => serverDisplayName(props.conn))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
- props.url
+ props.conn.http.url
props.status?.version
queueMicrotask(check)
})
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index c6e60d3ed..47030aa17 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -1,5 +1,5 @@
import { Match, Show, Switch, createMemo } from "solid-js"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
@@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
interface SessionContextUsageProps {
variant?: "button" | "indicator"
+ placement?: TooltipProps["placement"]
}
function openSessionContext(args: {
@@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
+
+ if (tabs().active() === "context") {
+ tabs().close("context")
+ return
+ }
openSessionContext({
view: view(),
layout,
@@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return (
-
+
{circle()}
diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx
new file mode 100644
index 000000000..aeb2e421b
--- /dev/null
+++ b/packages/app/src/components/session-todo-dock.tsx
@@ -0,0 +1,208 @@
+import type { Todo } from "@opencode-ai/sdk/v2"
+import { Checkbox } from "@opencode-ai/ui/checkbox"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+
+function dot(status: Todo["status"]) {
+ if (status !== "in_progress") return undefined
+ return (
+
+ )
+}
+
+export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
+ const [store, setStore] = createStore({
+ collapsed: false,
+ })
+
+ const toggle = () => setStore("collapsed", (value) => !value)
+
+ const summary = createMemo(() => {
+ const total = props.todos.length
+ if (total === 0) return ""
+ const completed = props.todos.filter((todo) => todo.status === "completed").length
+ return `${completed} of ${total} ${props.title.toLowerCase()} completed`
+ })
+
+ const active = createMemo(
+ () =>
+ props.todos.find((todo) => todo.status === "in_progress") ??
+ props.todos.find((todo) => todo.status === "pending") ??
+ props.todos.filter((todo) => todo.status === "completed").at(-1) ??
+ props.todos[0],
+ )
+
+ const preview = createMemo(() => active()?.content ?? "")
+
+ return (
+
+
{
+ if (event.key !== "Enter" && event.key !== " ") return
+ event.preventDefault()
+ toggle()
+ }}
+ >
+
{summary()}
+
+
+
+
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onClick={(event) => {
+ event.stopPropagation()
+ toggle()
+ }}
+ aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
+ />
+
+
+
+
+
+
+
+ )
+}
+
+function TodoList(props: { todos: Todo[]; open: boolean }) {
+ const [stuck, setStuck] = createSignal(false)
+ const [scrolling, setScrolling] = createSignal(false)
+ let scrollRef!: HTMLDivElement
+ let timer: number | undefined
+
+ const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
+
+ const ensure = () => {
+ if (!props.open) return
+ if (scrolling()) return
+ if (!scrollRef || scrollRef.offsetParent === null) return
+
+ const el = scrollRef.querySelector("[data-in-progress]")
+ if (!(el instanceof HTMLElement)) return
+
+ const topFade = 16
+ const bottomFade = 44
+ const container = scrollRef.getBoundingClientRect()
+ const rect = el.getBoundingClientRect()
+ const top = rect.top - container.top + scrollRef.scrollTop
+ const bottom = rect.bottom - container.top + scrollRef.scrollTop
+ const viewTop = scrollRef.scrollTop + topFade
+ const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
+
+ if (top < viewTop) {
+ scrollRef.scrollTop = Math.max(0, top - topFade)
+ } else if (bottom > viewBottom) {
+ scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
+ }
+
+ setStuck(scrollRef.scrollTop > 0)
+ }
+
+ createEffect(
+ on([() => props.open, inProgress], () => {
+ if (!props.open || inProgress() < 0) return
+ requestAnimationFrame(ensure)
+ }),
+ )
+
+ onCleanup(() => {
+ if (!timer) return
+ window.clearTimeout(timer)
+ })
+
+ return (
+
+
{
+ setStuck(e.currentTarget.scrollTop > 0)
+ setScrolling(true)
+ if (timer) window.clearTimeout(timer)
+ timer = window.setTimeout(() => {
+ setScrolling(false)
+ if (inProgress() < 0) return
+ requestAnimationFrame(ensure)
+ }, 250)
+ }}
+ >
+
+ {(todo) => (
+
+
+ {todo.content}
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 81220b3ad..162e016c6 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
+import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
-interface SessionContextTabProps {
- messages: () => Message[]
- visibleUserMessages: () => UserMessage[]
- view: () => ReturnType["view"]>
- info: () => ReturnType["session"]["get"]>
-}
-
const BREAKDOWN_COLOR: Record = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
@@ -91,11 +85,45 @@ function RawMessage(props: {
)
}
-export function SessionContextTab(props: SessionContextTabProps) {
+const emptyMessages: Message[] = []
+const emptyUserMessages: UserMessage[] = []
+
+export function SessionContextTab() {
const params = useParams()
const sync = useSync()
+ const layout = useLayout()
const language = useLanguage()
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const view = createMemo(() => layout.view(sessionKey))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+
+ const messages = createMemo(
+ () => {
+ const id = params.id
+ if (!id) return emptyMessages
+ return (sync.data.message[id] ?? []) as Message[]
+ },
+ emptyMessages,
+ { equals: same },
+ )
+
+ const userMessages = createMemo(
+ () => messages().filter((m) => m.role === "user") as UserMessage[],
+ emptyUserMessages,
+ { equals: same },
+ )
+
+ const visibleUserMessages = createMemo(
+ () => {
+ const revert = info()?.revert?.messageID
+ if (!revert) return userMessages()
+ return userMessages().filter((m) => m.id < revert)
+ },
+ emptyUserMessages,
+ { equals: same },
+ )
+
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
- const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const counts = createMemo(() => {
- const all = props.messages()
+ const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
- const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
+ const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo(
on(
- () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
+ () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
- messages: props.messages(),
+ messages: messages(),
parts: sync.data.part as Record,
input: c.input,
systemPrompt: systemPrompt(),
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}
const stats = [
- { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
+ { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
- { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
+ { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll
if (!el) return
- const s = props.view()?.scroll("context")
+ const s = view().scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined
if (!next) return
- props.view().setScroll("context", next)
+ view().setScroll("context", next)
})
}
createEffect(
on(
- () => props.messages().length,
+ () => messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{language.t("context.rawMessages.title")}
-
+
{(message) => (
)}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index f81a2ec44..ae8fc200f 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -257,27 +257,12 @@ export function SessionHeader() {
] as const
})
- const checksReady = createMemo(() => {
- if (platform.platform !== "desktop") return true
- if (!platform.checkAppExists) return true
- const list = apps()
- return list.every((app) => exists[app.id] !== undefined)
- })
-
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
- createEffect(() => {
- if (platform.platform !== "desktop") return
- if (!checksReady()) return
- const value = prefs.app
- if (options().some((o) => o.id === value)) return
- setPrefs("app", options()[0]?.id ?? "finder")
- })
-
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
@@ -311,25 +296,25 @@ export function SessionHeader() {
platform,
})
- const leftMount = createMemo(
- () => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
- )
+ const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
-
+
{(mount) => (
-