Compare commits

..

57 Commits

Author SHA1 Message Date
Adam
3347b591ce fix(app): better memory management of sessions 2026-02-12 19:12:35 -06:00
opencode
5bbc571276 release: v1.1.64 2026-02-12 18:11:28 -06:00
Ariane Emory
5cc461578b fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) 2026-02-12 18:11:27 -06:00
Aiden Cline
402cf49164 chore: cleanup flag code (#13389) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
afe9763668 chore: generate 2026-02-12 18:11:27 -06:00
Smit Chaudhary
e22426303a fix: look for recent model in fallback in cli (#12582) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
f148ec687e chore: update nix node_modules hashes 2026-02-12 18:11:26 -06:00
Luke Parker
27aa01d1b7 fix: baseline CPU detection (#13371) 2026-02-12 18:11:26 -06:00
Luke Parker
fe4f2b1b23 feat: windows selection behavior, manual ctrl+c (#13315) 2026-02-12 18:11:26 -06:00
Sebastian
14b65ee985 do not open console on error (#13374) 2026-02-12 18:11:26 -06:00
opencode-agent[bot]
e1b90d0340 chore: generate 2026-02-12 18:11:26 -06:00
Aman Kalra
559b2275c0 docs: update STACKIT provider documentation with typo fix (#13357)
Co-authored-by: amankalra172 <aman.kalra@st.ovgu.de>
2026-02-12 18:11:25 -06:00
Adam
20545c98cc fix(app): terminal pty isolation 2026-02-12 18:11:25 -06:00
Adam
2d13bda31a feat(app): option to turn off sound effects 2026-02-12 18:11:25 -06:00
Adam
79ee589099 fix(app): normalize oauth error messages 2026-02-12 18:11:25 -06:00
Adam
8d53d22c36 chore: cleanup 2026-02-12 18:11:25 -06:00
Adam
ddc3032b72 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
402fc9eed9 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
f554e5ee7a chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
a21a441409 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
c6500328ca fix(app): suggestion active state broken 2026-02-12 18:11:24 -06:00
Adam
ff1077b3ad fix(app): remote http server connections 2026-02-12 18:11:23 -06:00
Aiden Cline
90e248f6a0 test: add more test cases for project.test.ts (#13355) 2026-02-12 18:11:23 -06:00
opencode
61455e7d31 release: v1.1.63 2026-02-12 18:11:23 -06:00
Dax Raad
dcce83aaa0 improve codex model list 2026-02-12 18:11:23 -06:00
opencode
79fc63709c release: v1.1.62 2026-02-12 18:11:23 -06:00
Adam
6532b4fb76 fix(app): project icons unloading 2026-02-12 18:11:22 -06:00
Adam
0e7627f637 wip(app): timeline changes 2026-02-12 15:46:08 -06:00
Adam
12a80c4000 wip(app): timeline changes 2026-02-12 15:44:02 -06:00
Adam
713cc7339e wip(app): timeline changes 2026-02-12 15:35:09 -06:00
Adam
4da246ea01 wip(app): timeline changes 2026-02-12 15:19:54 -06:00
Adam
cbf9641642 wip(app): timeline changes 2026-02-12 15:07:55 -06:00
Adam
8e69ff0fe7 wip(app): timeline changes 2026-02-12 14:59:04 -06:00
David Hill
490967208c tweak(ui): show spinners after titles and lock tools until done 2026-02-12 11:38:05 -06:00
David Hill
2fc3bfefc0 fix(ui): improve bash output scrolling and wrapping 2026-02-12 11:38:05 -06:00
David Hill
f10787ef74 tweak(ui): remove horizontal padding from todos and question answers 2026-02-12 11:38:05 -06:00
David Hill
bda07f7d8f refactor(ui): rework bash output container and copy 2026-02-12 11:38:05 -06:00
David Hill
dec46fba39 tweak(ui): expand user message hover area 2026-02-12 11:38:05 -06:00
David Hill
383a0fb896 tweak(ui): add markdown copy tooltip and restyle button 2026-02-12 11:38:04 -06:00
David Hill
532d7e9d80 tweak(ui): prevent copy button layout shift 2026-02-12 11:38:04 -06:00
David Hill
881634f2e7 tweak(ui): adjust tool output spacing and title weight 2026-02-12 11:38:04 -06:00
David Hill
99e7521289 tweak(ui): remove context tool icons and add tool spacing 2026-02-12 11:38:04 -06:00
David Hill
5a663fbd23 tweak(ui): unify tool header typography 2026-02-12 11:38:04 -06:00
David Hill
1760e4fb6e tweak(ui): style edit/write tool content container 2026-02-12 11:38:03 -06:00
David Hill
c0eb553a94 tweak(ui): tighten response copy tooltip gutter 2026-02-12 11:38:03 -06:00
David Hill
c807319f31 tweak(ui): strengthen user message text 2026-02-12 11:38:03 -06:00
David Hill
f16466c996 tweak(ui): refine tool typography and truncation 2026-02-12 11:38:03 -06:00
David Hill
bc501167b2 fix(ui): allow explore agent output to fully expand 2026-02-12 11:38:03 -06:00
David Hill
536d3f73af tweak(ui): tighten chevron spacing 2026-02-12 11:38:02 -06:00
David Hill
594341d8f8 tweak(ui): style agent title and link 2026-02-12 11:38:02 -06:00
David Hill
3ba0265ad8 tweak(ui): flatten tool and collapsible UI 2026-02-12 11:38:02 -06:00
David Hill
e448e77c90 tweak(ui): refine copy tooltip text and spacing 2026-02-12 11:38:02 -06:00
David Hill
1c80f92281 tweak(ui): adjust user message bubble styling 2026-02-12 11:38:01 -06:00
Adam
b10885b557 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
c31e678391 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
d238344931 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
a74d6c3c23 wip(app): timeline changes 2026-02-12 11:38:00 -06:00
28 changed files with 1270 additions and 1623 deletions

View File

@@ -0,0 +1,85 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
function color(status: string) {
if (status === "completed") return "var(--icon-success-base)"
if (status === "in_progress") return "var(--icon-info-base)"
if (status === "cancelled") return "var(--icon-critical-base)"
return "var(--icon-weaker)"
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const progress = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed}/${total}`
})
const preview = createMemo(() => {
const active =
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos[0]
if (!active) return ""
return active.content
})
return (
<div class="mb-3 rounded-md border border-border-weak-base bg-surface-raised-stronger-non-alpha shadow-xs-border">
<div class="px-3 py-2 flex items-center gap-2">
<span class="text-12-medium text-text-strong">{props.title}</span>
<Show when={progress()}>
<span class="text-12-regular text-text-weak">{progress()}</span>
</Show>
<div class="ml-auto">
<IconButton
icon="chevron-down"
size="small"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setStore("collapsed", (value) => !value)}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<Show when={store.collapsed} fallback={<TodoList todos={props.todos} />}>
<div class="px-3 pb-3 text-12-regular text-text-base truncate">{preview()}</div>
</Show>
</div>
)
}
function TodoList(props: { todos: Todo[] }) {
return (
<div class="px-3 pb-3 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.todos}>
{(todo) => (
<div class="flex items-start gap-2 min-w-0">
<span style={{ color: color(todo.status) }} class="text-12-medium leading-5 shrink-0">
</span>
<span
class="text-12-regular min-w-0 break-words"
style={{
color: todo.status === "completed" || todo.status === "cancelled" ? "var(--text-weak)" : undefined,
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</div>
)}
</For>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import {
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -41,6 +42,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -87,12 +91,27 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
@@ -283,6 +302,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@@ -353,6 +373,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
todo: {
set: setSessionTodo,
},
}
}

View File

@@ -6,6 +6,7 @@ import {
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@@ -20,6 +21,9 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config

View File

@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {

View File

@@ -106,6 +106,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 400
const trimPageSize = 80
const fullSessionLimit = 5
const full = new Map<string, true>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -115,6 +118,112 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
loading: {} as Record<string, boolean>,
})
const touch = (key: string) => {
if (full.has(key)) full.delete(key)
full.set(key, true)
while (full.size > fullSessionLimit) {
const oldest = full.keys().next().value as string | undefined
if (!oldest) return
full.delete(oldest)
}
}
const evict = (input: { directory: string; store: Child[0]; setStore: Setter; keep?: string }) => {
const keep = new Set<string>()
if (input.keep) keep.add(input.keep)
for (const session of input.store.session) {
if (session?.id) keep.add(session.id)
}
const warm = new Set<string>()
for (const sessionID of keep) {
if (full.has(keyFor(input.directory, sessionID))) warm.add(sessionID)
}
if (input.keep) warm.add(input.keep)
const drop = new Set<string>()
const trim = new Set<string>()
for (const sessionID of Object.keys(input.store.message)) {
if (!keep.has(sessionID)) {
drop.add(sessionID)
continue
}
if (!warm.has(sessionID)) trim.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_diff)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.todo)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.permission)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.question)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_status)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
if (drop.size === 0 && trim.size === 0) return
input.setStore(
produce((draft) => {
for (const sessionID of drop) {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
full.delete(keyFor(input.directory, sessionID))
}
for (const sessionID of trim) {
const messages = draft.message[sessionID]
if (!messages) continue
const count = messages.length - trimPageSize
if (count <= 0) continue
for (const message of messages.slice(0, count)) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
draft.message[sessionID] = messages.slice(count)
}
}),
)
setMeta(
produce((draft) => {
for (const sessionID of drop) {
const key = keyFor(input.directory, sessionID)
delete draft.limit[key]
delete draft.complete[key]
delete draft.loading[key]
}
for (const sessionID of trim) {
const key = keyFor(input.directory, sessionID)
if (draft.limit[key] !== undefined && draft.limit[key] > trimPageSize) {
draft.limit[key] = trimPageSize
}
if (draft.complete[key] !== undefined) {
draft.complete[key] = false
}
}
}),
)
}
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -236,10 +345,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
if (hasSession && hasMessages && hydrated && full.has(key)) {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
return
}
const count = store.message[sessionID]?.length ?? 0
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const limit = hydrated ? Math.max(meta.limit[key] ?? messagePageSize, messagePageSize) : limitFor(count)
const sessionReq = hasSession
? Promise.resolve()
@@ -260,7 +373,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const messagesReq =
hasMessages && hydrated
hasMessages && hydrated && full.has(key)
? Promise.resolve()
: loadMessages({
directory,
@@ -270,7 +383,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit,
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
return runInflight(inflight, key, () =>
Promise.all([sessionReq, messagesReq]).then(() => {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
}),
)
},
async diff(sessionID: string) {
const directory = sdk.directory
@@ -289,12 +407,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
return
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},

View File

@@ -503,6 +503,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",

View File

@@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -20,6 +20,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
@@ -91,6 +92,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
@@ -556,7 +558,6 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -675,7 +676,8 @@ export default function Page() {
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
void sync.session.sync(id)
void sync.session.todo(id)
})
createEffect(() => {
@@ -728,13 +730,17 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@@ -753,12 +759,6 @@ export default function Page() {
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@@ -931,10 +931,8 @@ export default function Page() {
status,
userMessages,
visibleUserMessages,
activeMessage,
showAllFiles,
navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
addSelectionToContext,
focusInput,
@@ -1654,8 +1652,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>
@@ -1686,6 +1682,7 @@ export default function Page() {
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}

View File

@@ -88,8 +88,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@@ -164,8 +162,9 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
@@ -316,8 +315,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@@ -1,9 +1,10 @@
import { For, Show } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
export function SessionPromptDock(props: {
@@ -11,6 +12,7 @@ export function SessionPromptDock(props: {
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
@@ -122,6 +124,14 @@ export function SessionPromptDock(props: {
</div>
}
>
<Show when={props.todos.length > 0}>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</Show>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}

View File

@@ -42,10 +42,8 @@ export type SessionCommandContext = {
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
activeMessage: () => UserMessage | undefined
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
@@ -168,19 +166,6 @@ export const useSessionCommands = (input: SessionCommandContext) => {
input.view().terminal.open()
},
}),
viewCommand({
id: "steps.toggle",
title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !input.params.id,
onSelect: () => {
const msg = input.activeMessage()
if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
}),
])
const messageCommands = createMemo(() => [

View File

@@ -224,7 +224,6 @@ export default function () {
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
expandedSteps: {} as Record<string, boolean>,
})
const messages = createMemo(() =>
data().sessionID
@@ -296,10 +295,7 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@@ -375,13 +371,6 @@ export default function () {
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
stepsExpanded={
store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
}
onStepsExpandedToggle={() => {
const id = store.messageId ?? firstUserMessage()!.id!
setStore("expandedSteps", id, (v) => !v)
}}
classes={{
root: "grow",
content: "flex flex-col justify-between",

View File

@@ -4,15 +4,44 @@
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
justify-content: space-between;
gap: 0px;
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
gap: 8px;
}
[data-slot="basic-tool-tool-indicator"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="basic-tool-tool-spinner"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="icon-svg"] {
@@ -20,16 +49,17 @@
}
[data-slot="basic-tool-tool-info"] {
flex-grow: 1;
flex: 0 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
justify-content: flex-start;
}
[data-slot="basic-tool-tool-info-main"] {
@@ -43,16 +73,21 @@
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
@@ -62,12 +97,12 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
&.clickable {
cursor: pointer;
@@ -78,6 +113,26 @@
color: var(--text-base);
}
}
&.subagent-link {
color: var(--text-interactive-base);
text-decoration: none;
text-underline-offset: 2px;
font-weight: var(--font-weight-regular);
&:hover {
color: var(--text-interactive-base);
text-decoration: underline;
}
&:active {
color: var(--text-interactive-base);
}
&:visited {
color: var(--text-interactive-base);
}
}
}
[data-slot="basic-tool-tool-arg"] {
@@ -87,11 +142,11 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
}
}

View File

@@ -1,6 +1,7 @@
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
import { Spinner } from "./spinner"
export type TriggerTitle = {
title: string
@@ -22,6 +23,7 @@ export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
@@ -31,22 +33,23 @@ export interface BasicToolProps {
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const pending = () => props.status === "pending" || props.status === "running"
createEffect(() => {
if (props.forceOpen) setOpen(true)
})
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setOpen(value)
}
return (
<Collapsible open={open()} onOpenChange={handleOpenChange}>
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<Icon name={props.icon} size="small" />
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
@@ -61,39 +64,46 @@ export function BasicTool(props: BasicToolProps) {
>
{trigger().title}
</span>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
<Show when={pending()}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={trigger().action}>{trigger().action}</Show>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
@@ -101,7 +111,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked}>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
@@ -113,6 +123,6 @@ export function BasicTool(props: BasicToolProps) {
)
}
export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
}

View File

@@ -2,23 +2,44 @@
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--surface-inset-base);
border: 1px solid var(--border-weaker-base);
background-color: transparent;
border: none;
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
overflow: clip;
&.tool-collapsible {
gap: 8px;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
padding: 6px 8px 6px 12px;
padding: 0;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
color: var(--text-base);
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
}
[data-slot="collapsible-arrow-icon"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: inline-flex;
}
&:hover [data-slot="collapsible-arrow"] {
opacity: 1;
}
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -48,6 +69,20 @@
}
}
[data-slot="collapsible-trigger"][aria-expanded="true"] {
[data-slot="collapsible-arrow"] {
opacity: 1;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="down"] {
display: inline-flex;
}
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */

View File

@@ -34,7 +34,12 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<Icon name="chevron-grabber-vertical" size="small" />
<span data-slot="collapsible-arrow-icon" data-direction="right">
<Icon name="chevron-right" size="small" />
</span>
<span data-slot="collapsible-arrow-icon" data-direction="down">
<Icon name="chevron-down" size="small" />
</span>
</div>
)
}

View File

@@ -7,7 +7,7 @@
[data-slot="diff-changes-additions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
@@ -19,7 +19,7 @@
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);

View File

@@ -3,7 +3,7 @@
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: var(--line-height-x-large);
@@ -117,7 +117,7 @@
.shiki {
font-size: 13px;
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
}
@@ -127,11 +127,55 @@
[data-slot="markdown-copy-button"] {
position: absolute;
top: 8px;
right: 8px;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
&::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-sm);
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
opacity: 0;
transition: opacity 0.15s ease;
}
}
[data-slot="markdown-copy-button"]:hover::after,
[data-slot="markdown-copy-button"]:focus-visible::after {
opacity: 1;
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {

View File

@@ -72,7 +72,7 @@ function createCopyButton(labels: CopyLabels) {
button.setAttribute("data-size", "small")
button.setAttribute("data-slot", "markdown-copy-button")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
button.appendChild(createIcon(iconPaths.check, "check-icon"))
return button
@@ -82,12 +82,12 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
if (copied) {
button.setAttribute("data-copied", "true")
button.setAttribute("aria-label", labels.copied)
button.setAttribute("title", labels.copied)
button.setAttribute("data-tooltip", labels.copied)
return
}
button.removeAttribute("data-copied")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
}
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {

View File

@@ -14,15 +14,23 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: stretch;
width: 100%;
max-width: 100%;
gap: 8px;
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
}
[data-slot="user-message-attachment"] {
@@ -71,15 +79,24 @@
}
}
[data-slot="user-message-body"] {
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
}
[data-slot="user-message-text"] {
position: relative;
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
background: var(--surface-weak);
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
[data-highlight="file"] {
color: var(--syntax-property);
@@ -89,19 +106,32 @@
color: var(--syntax-type);
}
[data-slot="user-message-copy-wrapper"] {
position: absolute;
top: 7px;
right: 7px;
opacity: 0;
transition: opacity 0.15s ease;
}
max-width: 100%;
}
&:hover [data-slot="user-message-copy-wrapper"] {
opacity: 1;
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--text-strong);
}
@@ -115,21 +145,30 @@
width: 100%;
[data-slot="text-part-body"] {
position: relative;
margin-top: 32px;
margin-top: 0;
}
[data-slot="text-part-copy-wrapper"] {
position: absolute;
top: -28px;
right: 8px;
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 1;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
@@ -146,7 +185,7 @@
[data-component="markdown"] {
margin-top: 24px;
font-style: italic !important;
font-style: normal;
p:has(strong) {
margin-top: 24px;
@@ -196,7 +235,8 @@
[data-component="tool-output"] {
white-space: pre;
padding: 8px 12px;
padding: 0;
margin-bottom: 24px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -238,6 +278,78 @@
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]),
[data-slot="collapsible-content"]:has([data-component="write-content"]) {
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
overflow: hidden;
}
[data-component="bash-output"] {
width: 100%;
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
position: relative;
overflow: hidden;
[data-slot="bash-copy"] {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="bash-copy"],
&:focus-within [data-slot="bash-copy"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-slot="bash-scroll"] {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="bash-pre"] {
margin: 0;
padding: 12px;
}
[data-slot="bash-pre"] code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: var(--line-height-large);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"],
[data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] {
border-top: none;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
@@ -260,7 +372,7 @@
align-items: center;
gap: 4px;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
@@ -268,18 +380,37 @@
color: var(--text-base);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
@@ -344,7 +475,7 @@
}
[data-component="todos"] {
padding: 10px 12px 24px 48px;
padding: 10px 0 24px 0;
display: flex;
flex-direction: column;
gap: 8px;
@@ -357,25 +488,17 @@
}
}
[data-component="task-tools"] {
padding: 8px 12px;
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: flex-start;
gap: 0px;
cursor: pointer;
[data-slot="task-tool-item"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="icon-svg"] {
flex-shrink: 0;
color: var(--icon-weak);
}
}
[data-slot="task-tool-title"] {
[data-slot="context-tool-group-title"] {
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
@@ -383,15 +506,20 @@
color: var(--text-weak);
}
[data-slot="task-tool-subtitle"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weaker);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
}
}
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
}
}
@@ -720,7 +848,7 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 12px;
padding: 8px 0;
[data-slot="question-answer-item"] {
display: flex;

View File

@@ -37,18 +37,18 @@ import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
import { findLast } from "@opencode-ai/util/array"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
interface Diagnostic {
range: {
@@ -92,6 +92,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
export interface MessageProps {
message: MessageType
parts: PartType[]
showAssistantCopyPartID?: string
}
export interface MessagePartProps {
@@ -99,6 +100,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string
}
export type PartComponent = Component<MessagePartProps>
@@ -107,12 +109,6 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
const TEXT_RENDER_THROTTLE_MS = 100
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
@@ -157,22 +153,6 @@ function getDirectory(path: string | undefined) {
return relativizeProjectPaths(_getDirectory(path), data.directory)
}
export function getSessionToolParts(store: ReturnType<typeof useData>["store"], sessionId: string): ToolPart[] {
const messages = store.message[sessionId]?.filter((m) => m.role === "assistant")
if (!messages) return []
const parts: ToolPart[] = []
for (const m of messages) {
const msgParts = store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p && p.type === "tool") parts.push(p as ToolPart)
}
}
}
return parts
}
import type { IconProps } from "./icon"
export type ToolInfo = {
@@ -269,6 +249,86 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
function contextToolDetail(part: ToolPart): string | undefined {
const info = getToolInfo(part.tool, part.state.input ?? {})
if (info.subtitle) return info.subtitle
if (part.state.status === "error") return part.state.error
if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
return part.state.title
const description = part.state.input?.description
if (typeof description === "string") return description
return undefined
}
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/"
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined
const limit = typeof input.limit === "number" ? input.limit : undefined
switch (part.tool) {
case "read": {
const args: string[] = []
if (offset !== undefined) args.push("offset=" + offset)
if (limit !== undefined) args.push("limit=" + limit)
return {
title: i18n.t("ui.tool.read"),
subtitle: filePath ? getFilename(filePath) : "",
args,
}
}
case "list":
return {
title: i18n.t("ui.tool.list"),
subtitle: getDirectory(path),
}
case "glob":
return {
title: "Search",
subtitle: getDirectory(path),
args: pattern ? ["pattern=" + pattern] : [],
}
case "grep": {
const args: string[] = []
if (pattern) args.push("pattern=" + pattern)
if (include) args.push("include=" + include)
return {
title: "Search",
subtitle: getDirectory(path),
args,
}
}
default: {
const info = getToolInfo(part.tool, input)
return {
title: info.title,
subtitle: info.subtitle || contextToolDetail(part),
args: [],
}
}
}
}
function contextToolSummary(parts: ToolPart[]) {
const read = parts.filter((part) => part.tool === "read").length
const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
const list = parts.filter((part) => part.tool === "list").length
return [
read ? `${read} ${read === 1 ? "read" : "reads"}` : undefined,
search ? `${search} ${search === 1 ? "search" : "searches"}` : undefined,
list ? `${list} ${list === 1 ? "list" : "lists"}` : undefined,
].filter((value): value is string => !!value)
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -281,47 +341,162 @@ export function Message(props: MessageProps) {
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
</Match>
</Switch>
)
}
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
const emptyParts: PartType[] = []
const filteredParts = createMemo(
() =>
props.parts.filter((x) => {
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
}),
emptyParts,
{ equals: same },
export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string
}) {
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
keys.push(key)
items[key] = item
}
const parts = props.parts
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.id}`, {
type: "context",
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((part, index) => {
if (isContextGroupTool(part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${part.id}`, { type: "part", part })
})
flush(parts.length - 1)
return { keys, items }
})
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
return (
<Show when={item()}>
{(value) => {
const entry = value()
if (entry.type === "context") return <ContextToolGroup parts={entry.parts} />
return (
<Part
part={entry.part}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)
}}
</Show>
)
}}
</For>
)
}
function ContextToolGroup(props: { parts: ToolPart[] }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(() =>
props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const details = createMemo(() => {
const items = summary()
if (items.length === 0) return ""
return `: ${items.join(", ")}`
})
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<Show
when={pending()}
fallback={<span data-slot="context-tool-group-title">Gathered context{details()}</span>}
>
<span data-slot="context-tool-group-title">
<TextShimmer text="Gathering context" />
{details()}
</span>
</Show>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-component="context-tool-group-list">
<For each={props.parts}>
{(part) => {
const trigger = contextToolTrigger(part, i18n)
const running = part.state.status === "pending" || part.state.status === "running"
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{trigger.title}</span>
<Show when={running}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!running && trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
</Show>
<Show when={!running && trigger.args?.length}>
<For each={trigger.args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</Show>
</div>
</div>
</div>
</div>
</div>
</div>
)
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
)
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const [expanded, setExpanded] = createSignal(false)
const [canExpand, setCanExpand] = createSignal(false)
let textRef: HTMLDivElement | undefined
const updateCanExpand = () => {
const el = textRef
if (!el) return
if (expanded()) return
setCanExpand(el.scrollHeight > el.clientHeight + 2)
}
createResizeObserver(
() => textRef,
() => {
updateCanExpand()
},
)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -329,11 +504,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
createEffect(() => {
text()
updateCanExpand()
})
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@@ -364,13 +534,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
setTimeout(() => setCopied(false), 2000)
}
const toggleExpanded = () => {
if (!canExpand()) return
setExpanded((value) => !value)
}
return (
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<div data-component="user-message">
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -404,29 +569,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")}
onClick={(event) => {
event.stopPropagation()
toggleExpanded()
}}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-body">
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
<div data-slot="user-message-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
@@ -491,6 +647,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
</Show>
)
@@ -536,6 +693,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = props.part as ToolPart
if (part.tool === "todowrite" || part.tool === "todoread") return null
const hideQuestion = createMemo(
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
)
const permission = createMemo(() => {
const next = data.store.permission?.[props.message.sessionID]?.[0]
@@ -604,65 +766,67 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const render = ToolRegistry.render(part.tool) ?? GenericTool
return (
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={metadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
<Show when={showPermission() && permission()}>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={metadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
<Show when={showPermission() && permission()}>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</div>
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
)
}
@@ -672,6 +836,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const part = props.part as TextPart
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part.id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID) return props.showAssistantCopyPartID === part.id
return isLastTextPart()
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@@ -687,23 +862,25 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
</Show>
</div>
</Show>
)
@@ -844,29 +1021,47 @@ ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const url = createMemo(() => {
const value = props.input.url
if (typeof value !== "string") return ""
return value
})
return (
<BasicTool
{...props}
hideDetails
icon="window-cursor"
trigger={{
title: i18n.t("ui.tool.webfetch"),
subtitle: props.input.url || "",
args: props.input.format ? ["format=" + props.input.format] : [],
action: (
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{i18n.t("ui.tool.webfetch")}</span>
<Show when={pending()}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url()}
</a>
</Show>
</div>
),
}}
>
<Show when={props.output}>
{(output) => (
<div data-component="tool-output" data-scrollable>
<Markdown text={output()} />
</div>
)}
</Show>
</BasicTool>
<Show when={!pending() && url()}>
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
}
/>
)
},
})
@@ -877,6 +1072,13 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const childSessionId = () => props.metadata.sessionId as string | undefined
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
const description = createMemo(() => {
const value = props.input.description
if (typeof value === "string") return value
return undefined
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => {
const sessionId = childSessionId()
@@ -892,14 +1094,6 @@ ToolRegistry.register({
return `${path.slice(0, idx)}/session/${sessionId}`
})
createEffect(() => {
const sessionId = childSessionId()
if (!sessionId) return
const sync = data.syncSession
if (!sync) return
Promise.resolve(sync(sessionId)).catch(() => undefined)
})
const handleLinkClick = (e: MouseEvent) => {
const sessionId = childSessionId()
const url = href()
@@ -921,23 +1115,30 @@ ToolRegistry.register({
}, 50)
}
const titleContent = () => <TextShimmer text={title()} active={running()} />
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize">
{i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })}
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
{titleContent()}
</span>
<Show when={props.input.description}>
<Show when={description()}>
<Switch>
<Match when={href()}>
{(url) => (
<a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}>
{props.input.description}
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
onClick={handleLinkClick}
>
{description()}
</a>
)}
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{props.input.description}</span>
<span data-slot="basic-tool-tool-subtitle">{description()}</span>
</Match>
</Switch>
</Show>
@@ -945,134 +1146,7 @@ ToolRegistry.register({
</div>
)
const childToolParts = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return []
return getSessionToolParts(data.store, sessionId)
})
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "auto",
})
const childPermission = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return undefined
const permissions = data.store.permission?.[sessionId] ?? []
return permissions[0]
})
const childToolPart = createMemo(() => {
const perm = childPermission()
if (!perm || !perm.tool) return undefined
const sessionId = childSessionId()
if (!sessionId) return undefined
// Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? []
const message = findLast(messages, (m) => m.id === perm.tool!.messageID)
if (!message) return undefined
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) {
return { part: part as ToolPart, message }
}
}
return undefined
})
const respond = (response: "once" | "always" | "reject") => {
const perm = childPermission()
if (!perm || !data.respondToPermission) return
data.respondToPermission({
sessionID: perm.sessionID,
permissionID: perm.id,
response,
})
}
const renderChildToolPart = () => {
const toolData = childToolPart()
if (!toolData) return null
const { part } = toolData
const render = ToolRegistry.render(part.tool) ?? GenericTool
// @ts-expect-error
const metadata = part.state?.metadata ?? {}
const input = part.state?.input ?? {}
return (
<Dynamic
component={render}
input={input}
tool={part.tool}
metadata={metadata}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
defaultOpen={true}
/>
)
}
return (
<div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
<Switch>
<Match when={childPermission()}>
<>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</>
</Match>
<Match when={true}>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
data-component="tool-output"
data-scrollable
>
<div ref={autoScroll.contentRef} data-component="task-tools">
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state.input))
const subtitle = createMemo(() => {
if (info().subtitle) return info().subtitle
if (item.state.status === "completed" || item.state.status === "running") {
return item.state.title
}
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>
</Show>
</div>
)
}}
</For>
</div>
</div>
</BasicTool>
</Match>
</Switch>
</div>
)
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
},
})
@@ -1080,6 +1154,21 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
const out = stripAnsi(props.output || props.metadata.output || "")
return `$ ${cmd}${out ? "\n\n" + out : ""}`
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<BasicTool
{...props}
@@ -1089,10 +1178,28 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
<div data-component="tool-output" data-scrollable>
<Markdown
text={`\`\`\`command\n$ ${props.input.command ?? props.metadata.command ?? ""}${props.output || props.metadata.output ? "\n\n" + stripAnsi(props.output || props.metadata.output) : ""}\n\`\`\``}
/>
<div data-component="bash-output">
<div data-slot="bash-copy">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
<div data-slot="bash-scroll" data-scrollable>
<pre data-slot="bash-pre">
<code>{text()}</code>
</pre>
</div>
</div>
</BasicTool>
)
@@ -1106,6 +1213,7 @@ ToolRegistry.register({
const diffComponent = useDiffComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@@ -1115,16 +1223,23 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.edit")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
<Show when={pending()}>
<span data-slot="message-part-title-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={props.metadata.filediff}>
<Show when={!pending() && props.metadata.filediff}>
<DiffChanges changes={props.metadata.filediff} />
</Show>
</div>
@@ -1159,6 +1274,7 @@ ToolRegistry.register({
const codeComponent = useCodeComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@@ -1168,9 +1284,16 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.write")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
<Show when={pending()}>
<span data-slot="message-part-title-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>

View File

@@ -1,7 +1,5 @@
[data-component="session-turn"] {
--session-turn-sticky-height: 0px;
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 24px);
/* flex: 1; */
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -30,525 +28,30 @@
min-width: 0;
gap: 18px;
overflow-anchor: none;
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
}
[data-slot="session-turn-attachments"] {
width: 100%;
min-width: 0;
align-self: stretch;
}
[data-slot="session-turn-sticky"] {
width: calc(100% + 9px);
position: sticky;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
margin-left: -9px;
padding-left: 9px;
/* padding-bottom: 12px; */
display: flex;
flex-direction: column;
gap: 12px;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--background-stronger);
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to bottom, var(--background-stronger), transparent);
pointer-events: none;
}
}
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
align-self: stretch;
height: 32px;
}
[data-slot="session-turn-message-content"] {
margin-top: 0;
width: 100%;
min-width: 0;
max-width: 100%;
}
[data-component="user-message"] [data-slot="user-message-text"] {
max-height: var(--user-message-collapsed-height, 64px);
}
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
max-height: none;
}
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
padding-right: 36px;
padding-bottom: 28px;
}
[data-component="user-message"][data-can-expand="true"]:not([data-expanded="true"])
[data-slot="user-message-text"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 8px;
bottom: 0px;
background:
linear-gradient(to bottom, transparent, var(--surface-weak)),
linear-gradient(to bottom, transparent, var(--surface-weak));
pointer-events: none;
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
display: none;
position: absolute;
bottom: 6px;
right: 6px;
padding: 0;
}
[data-component="user-message"][data-can-expand="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"],
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: 22px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--text-weak);
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
}
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"]
[data-slot="icon-svg"] {
transform: rotate(180deg);
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
background: var(--surface-raised-base);
color: var(--text-base);
}
[data-slot="session-turn-user-badges"] {
display: flex;
align-items: center;
gap: 6px;
padding-left: 16px;
}
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: var(--font-size-large);
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-turn-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-turn-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
[data-slot="session-turn-summary-title-row"] {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="session-turn-response"] {
width: 100%;
}
[data-slot="session-turn-response-copy-wrapper"] {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-response-copy-wrapper"],
&:focus-within [data-slot="session-turn-response-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
p {
font-size: var(--font-size-base);
line-height: var(--line-height-x-large);
}
}
[data-slot="session-turn-summary-title"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-turn-markdown"],
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
[data-slot="session-turn-markdown"] {
&[data-diffs="true"] {
font-size: 15px;
}
&[data-fade="true"] > * {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
}
[data-slot="session-turn-summary-section"] {
position: relative;
[data-slot="session-turn-summary-copy"] {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-summary-copy"] {
opacity: 1;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 0px);
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-slot="session-turn-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
[data-expandable="false"] {
pointer-events: none;
}
}
[data-slot="session-turn-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-turn-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-turn-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-turn-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-turn-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-turn-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-turn-accordion-content"] {
max-height: 240px;
/* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-response-section"] {
width: calc(100% + 9px);
min-width: 0;
margin-left: -9px;
padding-left: 9px;
}
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
}
[data-slot="session-turn-collapsible-trigger-content"] {
max-width: 100%;
min-width: 0;
[data-slot="session-turn-thinking"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="session-turn-trigger-icon"] {
color: var(--icon-base);
}
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
min-height: 20px;
[data-component="spinner"] {
width: 12px;
height: 12px;
margin-right: 4px;
width: 16px;
height: 16px;
}
[data-component="icon"] {
width: 14px;
height: 14px;
}
}
[data-slot="session-turn-retry-message"] {
font-weight: 500;
color: var(--syntax-critical);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="session-turn-retry-seconds"] {
color: var(--text-weak);
}
[data-slot="session-turn-retry-attempt"] {
color: var(--text-weak);
}
[data-slot="session-turn-status-text"] {
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="session-turn-details-text"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
}
.error-card {
@@ -560,44 +63,16 @@
overflow-y: auto;
}
[data-slot="session-turn-collapsible-content-inner"] {
[data-slot="session-turn-assistant-content"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
margin-left: 12px;
padding-left: 12px;
padding-right: 12px;
border-left: 1px solid var(--border-base);
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
}
}
[data-slot="session-turn-permission-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-answered-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
}

View File

@@ -1,31 +1,12 @@
import {
AssistantMessage,
FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
type QuestionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
import { AssistantMessage, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { IconButton } from "./icon-button"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { Message } from "./message-part"
import { Card } from "./card"
import { Button } from "./button"
import { Spinner } from "./spinner"
import { Tooltip } from "./tooltip"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
type Translator = (key: UiI18nKey, params?: UiI18nParams) => string
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -80,117 +61,42 @@ function unwrap(message: string) {
return message
}
function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined {
if (!part) return undefined
if (part.type === "tool") {
switch (part.tool) {
case "task":
return t("ui.sessionTurn.status.delegating")
case "todowrite":
case "todoread":
return t("ui.sessionTurn.status.planning")
case "read":
return t("ui.sessionTurn.status.gatheringContext")
case "list":
case "grep":
case "glob":
return t("ui.sessionTurn.status.searchingCodebase")
case "webfetch":
return t("ui.sessionTurn.status.searchingWeb")
case "edit":
case "write":
return t("ui.sessionTurn.status.makingEdits")
case "bash":
return t("ui.sessionTurn.status.runningCommands")
default:
return undefined
}
}
if (part.type === "reasoning") {
const text = part.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() })
return t("ui.sessionTurn.status.thinking")
}
if (part.type === "text") {
return t("ui.sessionTurn.status.gatheringThoughts")
}
return undefined
}
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function isAttachment(part: PartType | undefined) {
if (part?.type !== "file") return false
const mime = (part as FilePart).mime ?? ""
return mime.startsWith("image/") || mime === "application/pdf"
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
hidden?: () => readonly { messageID: string; callID: string }[]
}) {
const hidden = new Set(["todowrite", "todoread"])
function visible(part: PartType) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return false
}
function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) {
const data = useData()
const emptyParts: PartType[] = []
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (part?.type === "text") return part as TextPart
}
return undefined
})
const filteredParts = createMemo(() => {
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (props.hideResponsePart) {
const responsePartId = props.responsePartId
if (responsePartId && responsePartId === lastTextPart()?.id) {
parts = parts.filter((part) => part?.id !== responsePartId)
}
}
const hidden = props.hidden?.() ?? []
if (hidden.length === 0) return parts
const id = props.message.id
return parts.filter((part) => {
if (part?.type !== "tool") return true
const tool = part as ToolPart
return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
})
})
return <Message message={props.message} parts={filteredParts()} />
return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
classes?: {
root?: string
@@ -199,16 +105,11 @@ export function SessionTurn(
}
}>,
) {
const i18n = useI18n()
const data = useData()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -256,19 +157,6 @@ export function SessionTurn(
return list(data.store.part?.[msg.id], emptyParts)
})
const attachmentParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyFiles
return msgParts.filter((part) => isAttachment(part)) as FilePart[]
})
const stickyParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyParts
if (attachmentParts().length === 0) return msgParts
return msgParts.filter((part) => !isAttachment(part))
})
const assistantMessages = createMemo(
() => {
const msg = message()
@@ -291,9 +179,24 @@ export function SessionTurn(
{ equals: same },
)
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -301,309 +204,23 @@ export function SessionTurn(
return unwrap(String(msg))
})
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = list(data.store.part?.[m.id], emptyParts)
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions))
const nextPermission = createMemo(() => permissions()[0])
const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions))
const nextQuestion = createMemo(() => questions()[0])
const hidden = createMemo(() => {
const out: { messageID: string; callID: string }[] = []
const perm = nextPermission()
if (perm?.tool) out.push(perm.tool)
const question = nextQuestion()
if (question?.tool) out.push(question.tool)
return out
})
const answeredQuestionParts = createMemo(() => {
if (props.stepsExpanded) return emptyQuestionParts
if (questions().length > 0) return emptyQuestionParts
const result: { part: ToolPart; message: AssistantMessage }[] = []
for (const msg of assistantMessages()) {
const parts = list(data.store.part?.[msg.id], emptyParts)
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.tool !== "question") continue
// @ts-expect-error metadata may not exist on all tool states
const answers = tool.state?.metadata?.answers
if (answers && answers.length > 0) {
result.push({ part: tool, message: msg })
}
}
}
return result
})
const shellModePart = createMemo(() => {
const p = parts()
if (p.length === 0) return
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = list(data.store.part?.[msgs[0].id], emptyParts)
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages)
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = list(data.store.part?.[msg.id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part, i18n.t)
}
}
}
return computeStatusFromPart(last, i18n.t)
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
// session_status is session-scoped; only show retry on the active (last) turn
if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
})
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = response() ?? ""
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
const updateStickyHeight = (height: number) => {
const root = rootRef()
if (!root) return
const next = Math.ceil(height)
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
}
function duration() {
const msg = message()
if (!msg) return ""
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(msg.time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
const locale = i18n.locale()
const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
return locale.startsWith("zh") ? human.replaceAll("、", "") : human
}
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter(visible).length
}, 0),
)
const autoScroll = createAutoScroll({
working,
onUserInteracted: props.onUserInteracted,
overflowAnchor: "auto",
})
createResizeObserver(
() => stickyRef(),
({ height }) => {
updateStickyHeight(height)
},
)
createEffect(() => {
const root = rootRef()
if (!root) return
const sticky = stickyRef()
if (!sticky) {
root.style.setProperty("--session-turn-sticky-height", "0px")
return
}
updateStickyHeight(sticky.getBoundingClientRect().height)
})
const [store, setStore] = createStore({
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
})
createEffect(() => {
const r = retry()
if (!r) {
setStore("retrySeconds", 0)
return
}
const updateSeconds = () => {
const next = r.next
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
}
updateSeconds()
const timer = setInterval(updateSeconds, 1000)
onCleanup(() => clearInterval(timer))
})
let retryLog = ""
createEffect(() => {
const r = retry()
if (!r) return
const key = `${r.attempt}:${r.next}:${r.message}`
if (key === retryLog) return
retryLog = key
console.warn("[session-turn] retry", {
sessionID: props.sessionID,
messageID: props.messageID,
attempt: r.attempt,
next: r.next,
raw: r.message,
parsed: unwrap(r.message),
})
})
let errorLog = ""
createEffect(() => {
const value = error()?.data?.message
if (value === undefined || value === null) return
const raw = typeof value === "string" ? value : String(value)
if (!raw) return
if (raw === errorLog) return
errorLog = raw
console.warn("[session-turn] assistant-error", {
sessionID: props.sessionID,
messageID: props.messageID,
raw,
parsed: unwrap(raw),
})
})
createEffect(() => {
const update = () => {
setStore("duration", duration())
}
update()
// Only keep ticking while the active (in-progress) turn is running.
if (!working()) return
const timer = setInterval(update, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
onCleanup(() => {
if (!statusTimeout) return
clearTimeout(statusTimeout)
overflowAnchor: "dynamic",
})
return (
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
<div data-component="session-turn" class={props.classes?.root}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@@ -619,185 +236,32 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
<Show when={attachmentParts().length > 0}>
<div data-slot="session-turn-attachments" aria-live="off">
<Message message={msg()} parts={attachmentParts()} />
</div>
</Show>
<div data-slot="session-turn-sticky" ref={setStickyRef}>
{/* User Message */}
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={stickyParts()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
aria-expanded={props.stepsExpanded}
>
<Switch>
<Match when={working()}>
<Spinner />
</Match>
<Match when={!props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-slot="session-turn-trigger-icon"
>
<path
d="M8.125 1.875H1.875L5 8.125L8.125 1.875Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
<Match when={props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-icon-base"
>
<path
d="M8.125 8.125H1.875L5 1.875L8.125 8.125Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
</Switch>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
const msg = unwrap(r.message)
return msg.length > 60 ? msg.slice(0, 60) + "..." : msg
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· {i18n.t("ui.sessionTurn.retry.retrying")}
{store.retrySeconds > 0
? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds })
: ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>
<span data-slot="session-turn-status-text">
{store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")}
</span>
</Match>
<Match when={props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.hide")}</span>
</Match>
<Match when={!props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.show")}</span>
</Match>
</Switch>
<span aria-hidden="true">·</span>
<span aria-live="off">{store.duration}</span>
</Button>
</div>
</Show>
</div>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
hidden={hidden}
/>
)}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
{/* Response */}
<div class="sr-only" aria-live="polite">
{!working() && response() ? response() : ""}
</div>
<Show when={!working() && response()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<div data-slot="session-turn-summary-title-row">
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
<Show when={response()}>
<div data-slot="session-turn-response-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</Show>
</div>
<div data-slot="session-turn-response">
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response() ?? ""}
cacheKey={responsePartId()}
/>
</div>
</div>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</Match>
</Switch>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={parts()} />
</div>
<Show when={working() && assistantVisible() === 0 && !error()}>
<div data-slot="session-turn-thinking">
<span>Thinking</span>
<Spinner style={{ width: "16px" }} />
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
showAssistantCopyPartID={showAssistantCopyPartID()}
/>
)}
</For>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>

View File

@@ -0,0 +1,43 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 1200ms;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
white-space: pre;
color: inherit;
}
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] {
animation-name: text-shimmer-char;
animation-duration: var(--text-shimmer-duration);
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index));
}
@keyframes text-shimmer-char {
0%,
100% {
color: var(--text-weaker);
}
30% {
color: var(--text-weak);
}
55% {
color: var(--text-base);
}
75% {
color: var(--text-strong);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
animation: none !important;
color: inherit;
}
}

View File

@@ -0,0 +1,36 @@
import { For, createMemo, type ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
export const TextShimmer = <T extends ValidComponent = "span">(props: {
text: string
class?: string
as?: T
active?: boolean
stepMs?: number
durationMs?: number
}) => {
const chars = createMemo(() => Array.from(props.text))
const active = () => props.active ?? true
return (
<Dynamic
component={props.as || "span"}
data-component="text-shimmer"
data-active={active()}
class={props.class}
aria-label={props.text}
style={{
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
}}
>
<For each={chars()}>
{(char, index) => (
<span data-slot="text-shimmer-char" aria-hidden="true" style={{ "--text-shimmer-index": `${index()}` }}>
{char}
</span>
)}
</For>
</Dynamic>
)
}

View File

@@ -50,8 +50,6 @@ export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -62,7 +60,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
}) => {
return {
get store() {
@@ -76,7 +73,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
}
},
})

View File

@@ -89,7 +89,7 @@ export const dict = {
"ui.message.expand": "Expand message",
"ui.message.collapse": "Collapse message",
"ui.message.copy": "Copy",
"ui.message.copied": "Copied!",
"ui.message.copied": "Copied",
"ui.message.attachment.alt": "attachment",
"ui.patch.action.deleted": "Deleted",

View File

@@ -48,6 +48,7 @@
@import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tag.css" layer(components);
@import "../components/text-shimmer.css" layer(components);
@import "../components/toast.css" layer(components);
@import "../components/tooltip.css" layer(components);
@import "../components/typewriter.css" layer(components);