Compare commits

...

29 Commits

Author SHA1 Message Date
Shantur Rathore
69cd3cf545 bumpVersion to 0.2.5 2025-11-27 19:34:30 +00:00
Shantur Rathore
042a45db0d Ensure autoscroll reacts to UI toggles 2025-11-27 19:20:55 +00:00
Shantur Rathore
cc45c16d73 Stabilize message stream autoscroll 2025-11-27 18:48:11 +00:00
Shantur Rathore
91fb351a63 Improve sidebar default width and message autoscroll 2025-11-27 18:24:45 +00:00
Shantur Rathore
d9b149a7cb Reintroduce scroll restore effect for message stream 2025-11-27 17:25:19 +00:00
Shantur Rathore
222a467a19 Improve message stream caching and scroll performance 2025-11-27 16:51:05 +00:00
Shantur Rathore
18513939f7 Tighten message spacing and restyle reasoning blocks 2025-11-27 13:53:52 +00:00
Shantur Rathore
c123714271 Add thinking expansion preference and step finish styling 2025-11-27 13:39:03 +00:00
Shantur Rathore
5c82a2d653 Align assistant metadata display with message content 2025-11-27 13:26:31 +00:00
Shantur Rathore
435881529e match thinking toggle button sizing 2025-11-27 13:10:56 +00:00
Shantur Rathore
700342670c refine thinking accordion layout 2025-11-27 13:05:52 +00:00
Shantur Rathore
2f40f5eedf refine step and stream spacing 2025-11-27 10:41:34 +00:00
Shantur Rathore
54905c5626 tighten message spacing 2025-11-27 10:30:30 +00:00
Shantur Rathore
1bf1a4761d soften assistant and thinking headers 2025-11-27 10:27:29 +00:00
Shantur Rathore
755695a35a refine thinking cards and message layout 2025-11-27 10:24:41 +00:00
Shantur Rathore
6a9a442948 Handle session cleanup and error message status 2025-11-26 16:20:02 +00:00
Shantur Rathore
3db9b0f673 tidy normalized store hydration 2025-11-26 15:59:24 +00:00
Shantur Rathore
4e0e5dcdca Restore tool navigation and balanced scroll controls 2025-11-26 15:28:48 +00:00
Shantur Rathore
fad2809299 Improve message stream caching and virtualization for large sessions 2025-11-26 13:30:20 +00:00
Shantur Rathore
c77bfc2ee7 Avoid deep reconcile in message hydrate 2025-11-26 11:08:54 +00:00
Shantur Rathore
f1fa28dd2c Optimize message hydrate to reduce traversal 2025-11-26 10:59:15 +00:00
Shantur Rathore
91ace25333 Batch hydrate normalized messages for session load 2025-11-26 10:57:39 +00:00
Shantur Rathore
b54db28fb1 avoid deep proxying message info 2025-11-26 10:29:14 +00:00
Shantur Rathore
f13feb3062 Revert "cap session order/history lengths"
This reverts commit 4622bdc7ea.
2025-11-26 10:24:58 +00:00
Shantur Rathore
4622bdc7ea cap session order/history lengths 2025-11-26 10:23:49 +00:00
Shantur Rathore
919127b6d9 fix session closing crash 2025-11-26 10:20:08 +00:00
Shantur Rathore
27cd4515cd finish migration to message-store 2025-11-26 10:13:05 +00:00
Shantur Rathore
93a5c16cab migrate session event/actions to v2 store 2025-11-26 09:57:21 +00:00
Shantur Rathore
16b76385e2 chore: add message store v2 baseline 2025-11-26 09:42:10 +00:00
49 changed files with 3865 additions and 2014 deletions

View File

@@ -1,6 +1,5 @@
---
description: Develops Web UI components.
mode: all
model: zai-coding-plan/glm-4.6
---
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.

View File

@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
│ │ │ State Management (SolidJS Stores) │ │ │
│ │ │ - instances[] │ │ │
│ │ │ - sessions[] per instance │ │ │
│ │ │ - messages[] per session │ │ │
│ │ │ - normalized message store per session │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStream │ │ │
│ │ │ - MessageStreamV2 │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │

View File

@@ -49,7 +49,7 @@ packages/opencode-client/
│ ├── components/
│ │ ├── instance-tabs.tsx # Level 1 tabs
│ │ ├── session-tabs.tsx # Level 2 tabs
│ │ ├── message-stream.tsx # Messages display
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
│ │ ├── message-item.tsx # Single message
│ │ ├── tool-call.tsx # Tool execution display
│ │ ├── prompt-input.tsx # Input with attachments
@@ -153,16 +153,24 @@ interface Session {
providerId: string
modelId: string
}
messages: Message[]
status: SessionStatus
createdAt: number
updatedAt: number
version: string
time: { created: number; updated: number }
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
// Message content lives in the normalized message-v2 store
// keyed by instanceId/sessionId/messageId
type SessionStatus =
| "idle" // No activity
| "streaming" // Assistant responding
| "error" // Error occurred
```
### UI Store

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.2.4",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -8613,7 +8613,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -8641,7 +8641,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -8680,14 +8680,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.2.4",
"version": "0.2.5",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -59,7 +59,7 @@ export function setupStorageIPC() {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.4",
"version": "0.2.5",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.4",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.4",
"version": "0.2.5",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -10,6 +10,7 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"type": "module",
"scripts": {

View File

@@ -51,6 +51,7 @@ const App: Component = () => {
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
@@ -210,6 +211,7 @@ const App: Component = () => {
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,

View File

@@ -1,9 +1,10 @@
import { createMemo, Show, onMount, createEffect } from "solid-js"
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setToolRenderCache } from "../lib/tool-render-cache"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
import type { DiffViewMode } from "../stores/preferences"
interface ToolCallDiffViewerProps {
@@ -13,7 +14,7 @@ interface ToolCallDiffViewerProps {
mode: DiffViewMode
onRendered?: () => void
cachedHtml?: string
cacheKey?: string
cacheEntryParams?: CacheEntryParams
}
type DiffData = {
@@ -22,6 +23,13 @@ type DiffData = {
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const diffData = createMemo<DiffData | null>(() => {
const normalized = normalizeDiffText(props.diffText)
@@ -46,30 +54,93 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
})
let diffContainerRef: HTMLDivElement | undefined
let pendingCapture: number | undefined
let pendingContext: CaptureContext | undefined
let lastRenderedMarkup: string | undefined
let lastCachedHtml: string | undefined
const captureAndCacheHtml = () => {
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
// Extract the rendered HTML from DiffView container
const renderedHtml = diffContainerRef.innerHTML
if (renderedHtml) {
setToolRenderCache(props.cacheKey, {
text: props.diffText,
html: renderedHtml,
theme: props.theme,
mode: props.mode,
const clearPendingCapture = () => {
if (pendingCapture !== undefined) {
cancelAnimationFrame(pendingCapture)
pendingCapture = undefined
}
pendingContext = undefined
}
const runCapture = (context: CaptureContext) => {
if (!diffContainerRef) {
props.onRendered?.()
return
}
const markup = diffContainerRef.innerHTML
if (!markup) {
props.onRendered?.()
return
}
const hasChanged = markup !== lastRenderedMarkup
if (hasChanged) {
lastRenderedMarkup = markup
if (context.cacheEntryParams) {
setCacheEntry(context.cacheEntryParams, {
text: context.diffText,
html: markup,
theme: context.theme,
mode: context.mode,
})
}
}
props.onRendered?.()
}
// Also capture HTML when diff data changes
const scheduleCapture = (context: CaptureContext) => {
clearPendingCapture()
pendingContext = context
pendingCapture = requestAnimationFrame(() => {
const activeContext = pendingContext
pendingContext = undefined
pendingCapture = undefined
if (activeContext) {
runCapture(activeContext)
}
})
}
createEffect(() => {
const data = diffData()
if (data && !props.cachedHtml) {
// Delay to allow DiffView to re-render with new data
setTimeout(captureAndCacheHtml, 100)
const cachedHtml = props.cachedHtml
if (cachedHtml) {
clearPendingCapture()
if (cachedHtml !== lastCachedHtml) {
lastCachedHtml = cachedHtml
lastRenderedMarkup = cachedHtml
props.onRendered?.()
}
return
}
lastCachedHtml = undefined
const data = diffData()
const theme = props.theme
const mode = props.mode
if (!data) {
clearPendingCapture()
return
}
scheduleCapture({
theme,
mode,
diffText: props.diffText,
cacheEntryParams: props.cacheEntryParams,
})
})
onCleanup(() => {
clearPendingCapture()
})
return (

View File

@@ -1,26 +1,27 @@
import { For, Show, createMemo } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { For, Show } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { formatTokenTotal } from "../lib/formatters"
import { preferences } from "../stores/preferences"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts?: ClientPart[]
combinedParts: ClientPart[]
orderedParts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
const isUser = () => props.record.role === "user"
const timestamp = () => {
const date = new Date(props.message.timestamp)
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
const date = new Date(createdTime)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
@@ -30,10 +31,10 @@ export default function MessageItem(props: MessageItemProps) {
filename?: string
}
const displayParts = () => props.parts ?? props.message.parts
const combinedParts = () => props.combinedParts
const fileAttachments = () =>
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
props.orderedParts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) {
@@ -123,7 +124,7 @@ export default function MessageItem(props: MessageItemProps) {
return true
}
return displayParts().some((part) => partHasRenderableText(part))
return combinedParts().some((part) => partHasRenderableText(part))
}
const isGenerating = () => {
@@ -133,53 +134,19 @@ export default function MessageItem(props: MessageItemProps) {
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.message.id)
props.onRevert(props.record.id)
}
}
if (!isUser() && !hasContent()) {
return null
}
const containerClass = () =>
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const statChipClass =
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
const statValueClass = "font-semibold text-[var(--text-primary)]"
const usageStats = createMemo(() => {
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
if (!showUsageMetrics()) {
return null
}
const tokens = info.tokens
const input = tokens.input ?? 0
const output = tokens.output ?? 0
const reasoning = tokens.reasoning ?? 0
if (input === 0 && output === 0 && reasoning === 0) {
return null
}
return {
input,
output,
reasoning,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
})
const formatCostValue = (value: number) => {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
const agentIdentifier = () => {
if (isUser()) return ""
const info = props.messageInfo
@@ -196,24 +163,30 @@ export default function MessageItem(props: MessageItemProps) {
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
return (
<div class={containerClass()}>
<div class="flex justify-between items-center gap-2.5 pb-0.5">
<div class="flex flex-col">
<Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<div class={containerClass()}>
<div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="flex flex-col">
<Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<Show when={!isUser()}>
<div class="flex flex-wrap items-center gap-2 text-xs text-[var(--message-assistant-border)]">
<span class="font-semibold">Assistant</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
</span>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={!isUser()}>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={isUser() && props.onRevert}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
@@ -227,7 +200,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={isUser() && props.onFork}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={() => props.onFork?.(props.message.id)}
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
@@ -254,104 +227,71 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={displayParts()}>
<For each={combinedParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}
</For>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={usageStats()}>
{(usage) => (
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
<div class={statChipClass}>
<span class={statLabelClass}>Input</span>
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Output</span>
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Reasoning</span>
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Read</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Write</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cost</span>
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments mt-1">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
)}
</Show>
<Show when={props.message.status === "sending"}>
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.message.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
</div>
)
}

View File

@@ -33,6 +33,38 @@ export default function MessagePart(props: MessagePartProps) {
return ""
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
return true
}
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
return true
}
if (Array.isArray(candidate.content)) {
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
}
}
return false
}
const hasReasoningContent = () => {
if (props.part?.type !== "reasoning") {
return false
}
if (reasoningSegmentHasText((props.part as any).text)) {
return true
}
if (Array.isArray((props.part as any).content)) {
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
}
return false
}
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
@@ -83,23 +115,7 @@ export default function MessagePart(props: MessagePartProps) {
<Match when={partType() === "reasoning"}>
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
<div class="message-reasoning">
<div class="reasoning-container">
<div class="reasoning-header" onClick={handleReasoningClick}>
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
<span class="reasoning-label">Reasoning</span>
</div>
<Show when={isReasoningExpanded()}>
<div class={`${textContainerClass()} mt-2`}>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</div>
</Show>
</div>
</div>
</Show>
</Match>
</Switch>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,732 +0,0 @@
import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js"
import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
// Import ToolState types from SDK
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
// Type guards
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
return state.status === "running"
}
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
return state.status === "completed"
}
function isToolStateError(state: ToolState): state is ToolStateError {
return state.status === "error"
}
// Type guard to check if a part is a tool part
function isToolPart(part: ClientPart): part is ToolCallPart {
return part.type === "tool"
}
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd"
import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { formatTokenTotal } from "../lib/formatters"
import { setActiveInstanceId } from "../stores/instances"
import { showCommandPalette } from "../stores/command-palette"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
const SCROLL_DIRECTION_THRESHOLD = 10
interface TaskSessionLocation {
sessionId: string
instanceId: string
parentId: string | null
}
const messageScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
if (!sessionId) return null
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId,
parentId: session.parentId ?? null,
}
}
}
return null
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
setActiveSession(location.instanceId, location.sessionId)
}
}
// Format tokens like session sidebar (comma-separated totals)
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
interface MessageStreamProps {
instanceId: string
sessionId: string
messages: Message[]
messagesInfo?: Map<string, MessageInfo>
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
}
interface MessageDisplayItem {
type: "message"
message: Message
combinedParts: ClientPart[]
isQueued: boolean
messageInfo?: MessageInfo
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
}
type DisplayItem = MessageDisplayItem | ToolDisplayItem
interface MessageCacheEntry {
message: Message
version: number
showThinking: boolean
isQueued: boolean
messageInfo?: MessageInfo
displayParts: MessageDisplayParts
item: MessageDisplayItem
}
interface ToolCacheEntry {
toolPart: ClientPart
messageInfo?: MessageInfo
signature: string
contentKey: string
item: ToolDisplayItem
}
interface SessionCache {
messageItemCache: Map<string, MessageCacheEntry>
toolItemCache: Map<string, ToolCacheEntry>
}
const sessionCaches = new Map<string, SessionCache>()
function getSessionCache(instanceId: string, sessionId: string): SessionCache {
const key = `${instanceId}:${sessionId}`
let cache = sessionCaches.get(key)
if (!cache) {
cache = {
messageItemCache: new Map(),
toolItemCache: new Map(),
}
sessionCaches.set(key, cache)
}
return cache
}
export default function MessageStream(props: MessageStreamProps) {
const { preferences } = useConfig()
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const sessionCache = getSessionCache(props.instanceId, props.sessionId)
let messageItemCache = sessionCache.messageItemCache
let toolItemCache = sessionCache.toolItemCache
let scrollAnimationFrame: number | null = null
let lastKnownScrollTop = 0
const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}`
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
return `${messageId}:${partId}`
}
function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string {
const state = isToolPart(toolPart) ? toolPart.state : undefined
const version = typeof toolPart?.version === "number" ? toolPart.version : 0
const status = state?.status ?? "unknown"
return `${toolPart.id}:${version}:${status}`
}
const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const tokenStats = createMemo(() => {
const info = sessionInfo()
return {
input: info.inputTokens ?? 0,
output: info.outputTokens ?? 0,
cost: info.cost ?? 0,
used: info.actualUsageTokens ?? 0,
avail: info.contextAvailableTokens,
}
})
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
const { scrollTop, scrollHeight, clientHeight } = element
const distance = scrollHeight - (scrollTop + clientHeight)
return distance <= offset
}
function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) {
return element.scrollTop <= offset
}
function scrollToBottom(options: { smooth?: boolean } = {}) {
if (!containerRef) return
const behavior = options.smooth ? "smooth" : "auto"
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
updateScrollIndicators(containerRef)
})
}
function scrollToTop(options: { smooth?: boolean } = {}) {
if (!containerRef) return
const behavior = options.smooth ? "smooth" : "auto"
setAutoScroll(false)
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: 0, behavior })
setShowScrollTopButton(false)
updateScrollIndicators(containerRef)
})
}
function handleScroll(event: Event) {
if (!containerRef) return
if (scrollAnimationFrame !== null) {
cancelAnimationFrame(scrollAnimationFrame)
}
const isUserScroll = event.isTrusted
scrollAnimationFrame = requestAnimationFrame(() => {
if (!containerRef) return
const currentScrollTop = containerRef.scrollTop
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
lastKnownScrollTop = currentScrollTop
const atBottom = isNearBottom(containerRef)
if (isUserScroll) {
if (movingUp && !atBottom && autoScroll()) {
setAutoScroll(false)
} else if (!movingUp && atBottom && !autoScroll()) {
setAutoScroll(true)
}
}
updateScrollIndicators(containerRef)
scrollAnimationFrame = null
})
}
const messageView = createMemo(() => {
const showThinking = preferences().showThinkingBlocks
const items: DisplayItem[] = []
const newMessageCache = new Map<string, MessageCacheEntry>()
const newToolCache = new Map<string, ToolCacheEntry>()
const tokenSegments: string[] = []
let lastAssistantIndex = -1
for (let i = props.messages.length - 1; i >= 0; i--) {
if (props.messages[i].type === "assistant") {
lastAssistantIndex = i
break
}
}
tokenSegments.push(`count:${props.messages.length}`)
tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`)
tokenSegments.push(`thinking:${showThinking ? 1 : 0}`)
for (let index = 0; index < props.messages.length; index++) {
const message = props.messages[index]
const messageInfo = props.messagesInfo?.get(message.id)
if (props.revert?.messageID && message.id === props.revert.messageID) {
break
}
tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`)
const baseDisplayParts = message.displayParts
const displayParts: MessageDisplayParts =
!baseDisplayParts || baseDisplayParts.showThinking !== showThinking
? computeDisplayParts(message, showThinking)
: (baseDisplayParts as MessageDisplayParts)
const combinedParts = displayParts.combined
const version = message.version ?? 0
const isQueued = message.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
const hasRenderableContent =
message.type !== "assistant" ||
combinedParts.length > 0 ||
Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) ||
message.status === "error"
if (hasRenderableContent) {
const cacheEntry = messageItemCache.get(message.id)
if (
cacheEntry &&
cacheEntry.version === version &&
cacheEntry.showThinking === showThinking &&
cacheEntry.isQueued === isQueued &&
cacheEntry.messageInfo === messageInfo
) {
cacheEntry.displayParts = displayParts
cacheEntry.version = version
cacheEntry.showThinking = showThinking
cacheEntry.isQueued = isQueued
cacheEntry.messageInfo = messageInfo
cacheEntry.item.message = message
cacheEntry.item.combinedParts = combinedParts
cacheEntry.item.isQueued = isQueued
cacheEntry.item.messageInfo = messageInfo
newMessageCache.set(message.id, cacheEntry)
items.push(cacheEntry.item)
} else {
const messageItem: MessageDisplayItem = {
type: "message",
message,
combinedParts,
isQueued,
messageInfo,
}
newMessageCache.set(message.id, {
message,
version,
showThinking,
isQueued,
messageInfo,
displayParts,
item: messageItem,
})
items.push(messageItem)
}
}
const toolParts = displayParts.tool.filter(isToolPart)
for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) {
const toolPart = toolParts[toolIndex]
const originalIndex = displayParts.tool.indexOf(toolPart)
const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}`
const messageVersion = typeof message.version === "number" ? message.version : 0
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
const contentKey = createToolContentKey(toolPart, messageInfo)
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
const toolEntry = toolItemCache.get(toolKey)
if (toolEntry && toolEntry.signature === toolSignature) {
if (toolEntry.contentKey !== contentKey) {
const updatedItem: ToolDisplayItem = {
...toolEntry.item,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
toolEntry.signature = toolSignature
toolEntry.contentKey = contentKey
toolEntry.item = updatedItem
console.debug("[ToolCall] update", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, toolEntry)
items.push(updatedItem)
} else {
const cachedItem = toolEntry.item
cachedItem.toolPart = toolPart
cachedItem.messageInfo = messageInfo
cachedItem.messageId = message.id
cachedItem.messageVersion = messageVersion
cachedItem.partVersion = partVersion
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
newToolCache.set(toolKey, toolEntry)
items.push(cachedItem)
}
} else {
const toolItem: ToolDisplayItem = {
type: "tool",
key: toolKey,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
console.debug("[ToolCall] create", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
items.push(toolItem)
}
}
}
messageItemCache = newMessageCache
toolItemCache = newToolCache
sessionCache.messageItemCache = messageItemCache
sessionCache.toolItemCache = toolItemCache
tokenSegments.push(`items:${items.length}`)
if (items.length > 0) {
const tail = items[items.length - 1]
if (tail.type === "message") {
tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`)
} else {
tokenSegments.push(`tail:${tail.key}`)
}
}
return { items, token: tokenSegments.join("|") }
})
const displayItems = () => messageView().items
const changeToken = () => messageView().token
function updateScrollIndicators(element: HTMLDivElement) {
const itemsLength = displayItems().length
setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0)
setShowScrollTopButton(!isNearTop(element) && itemsLength > 0)
persistScrollState()
}
function getActiveScrollKey() {
return containerRef?.dataset.scrollKey || scrollStateKey()
}
function persistScrollState() {
if (!containerRef) return
const key = getActiveScrollKey()
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
}
createEffect(() => {
const key = scrollStateKey()
if (containerRef) {
containerRef.dataset.scrollKey = key
}
const savedState = messageScrollState.get(key)
const shouldAutoScroll = savedState?.autoScroll ?? true
setAutoScroll(shouldAutoScroll)
requestAnimationFrame(() => {
if (!containerRef) return
if (savedState) {
if (shouldAutoScroll) {
scrollToBottom({ smooth: false })
} else {
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop)
updateScrollIndicators(containerRef)
}
} else {
scrollToBottom({ smooth: false })
}
})
onCleanup(() => {
if (containerRef) {
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
if (containerRef.dataset.scrollKey === key) {
delete containerRef.dataset.scrollKey
}
}
})
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const shouldScroll = autoScroll()
if (!token || token === previousToken) {
return
}
previousToken = token
if (!shouldScroll) {
return
}
scrollToBottom()
})
createEffect(() => {
if (displayItems().length === 0) {
setShowScrollBottomButton(false)
setShowScrollTopButton(false)
setAutoScroll(true)
persistScrollState()
}
})
onCleanup(() => {
if (scrollAnimationFrame !== null) {
cancelAnimationFrame(scrollAnimationFrame)
}
})
return (
<div class="message-stream-container">
<div class="connection-status">
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
</span>
</div>
</div>
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button
type="button"
class="connection-status-button"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
Connected
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
Connecting...
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
Disconnected
</span>
</Show>
</div>
</div>
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
<Show when={!props.loading && displayItems().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
</div>
<h3>Start a conversation</h3>
<p>Type a message below or open the Command Palette:</p>
<ul>
<li>
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
</li>
<li>Ask about your codebase</li>
<li>
Attach files with <code>@</code>
</li>
</ul>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
</div>
</Show>
<For each={displayItems()} fallback={null}>
{(item) => {
if (item.type === "message") {
return (
<MessageItem
message={item.message}
messageInfo={item.messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={item.isQueued}
parts={item.combinedParts}
onRevert={props.onRevert}
onFork={props.onFork}
/>
)
}
const toolPart = item.toolPart
const toolState = toolPart.state
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
const taskSessionId =
hasToolState && typeof toolState?.metadata?.sessionId === "string"
? toolState.metadata.sessionId
: ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const handleGoToTaskSession = (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return (
<div class="tool-call-message" data-key={item.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolPart}
toolCallId={item.key}
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
}}
</For>
</div>
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToTop({ smooth: true })}
aria-label="Scroll to first message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom({ smooth: true })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
</div>
</Show>
</div>
)
}

View File

@@ -25,7 +25,7 @@ interface SessionListProps {
const MIN_WIDTH = 200
const MAX_WIDTH = 520
const DEFAULT_WIDTH = 350
const DEFAULT_WIDTH = 360
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string {

View File

@@ -1,13 +1,18 @@
import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js"
import { Show, createMemo, createEffect, type Component } from "solid-js"
import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageStream from "../message-stream"
import MessageStreamV2 from "../message-stream-v2"
import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts"
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
return part?.type === "text" && typeof (part as any).text === "string"
}
interface SessionViewProps {
sessionId: string
activeSessions: Map<string, Session>
@@ -19,6 +24,7 @@ interface SessionViewProps {
export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
createEffect(() => {
const currentSession = session()
@@ -36,23 +42,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
function getUserMessageText(messageId: string): string | null {
const currentSession = session()
if (!currentSession) return null
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
const targetInfo = currentSession.messagesInfo.get(messageId)
if (!targetMessage || targetInfo?.role !== "user") {
return null
const normalizedMessage = messageStore().getMessage(messageId)
if (normalizedMessage && normalizedMessage.role === "user") {
const parts = normalizedMessage.partIds
.map((partId) => normalizedMessage.parts[partId]?.data)
.filter((part): part is ClientPart => Boolean(part))
const textParts = parts.filter(isTextPart)
if (textParts.length > 0) {
return textParts.map((part) => part.text).join("\n")
}
}
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
if (textParts.length === 0) {
return null
}
return textParts.map((p) => p.text).join("\n")
return null
}
async function handleRevert(messageId: string) {
const instance = instances().get(props.instanceId)
if (!instance || !instance.client) return
@@ -127,29 +131,30 @@ export const SessionView: Component<SessionViewProps> = (props) => {
</div>
}
>
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
revert={s().revert}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
/>
{(sessionAccessor) => {
const activeSession = sessionAccessor()
if (!activeSession) return null
return (
<div class="session-view">
<MessageStreamV2
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
/>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={s().id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
/>
</div>
)}
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
/>
</div>
)
}}
</Show>
)
}

View File

@@ -1,15 +1,17 @@
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { messageStoreBus } from "../stores/message-v2/bus"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme"
import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
import type { TextPart, SDKPart, ClientPart, RenderCache } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -33,46 +35,17 @@ function isToolStateError(state: ToolState): state is ToolStateError {
}
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
const TOOL_CALL_CACHE_SCOPE = "tool-call"
function makeRenderCacheKey(
toolCallId?: string | null,
messageId?: string,
messageVersion?: number,
partVersion?: number,
partId?: string | null,
variant = "default",
) {
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
const keyBase = `${messageId}:${toolCallId}`
return `${keyBase}::${suffix}`
}
function updateScrollState(id: string, element: HTMLElement) {
if (!id) return
const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight)
const atBottom = distanceFromBottom <= 2
toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom })
}
function restoreScrollState(id: string, element: HTMLElement) {
if (!id) return
const state = toolScrollState.get(id)
if (!state) {
requestAnimationFrame(() => {
element.scrollTop = element.scrollHeight
updateScrollState(id, element)
})
return
}
requestAnimationFrame(() => {
if (state.atBottom) {
element.scrollTop = element.scrollHeight
} else {
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
element.scrollTop = Math.min(state.scrollTop, maxScrollTop)
}
updateScrollState(id, element)
})
const messageComponent = messageId ?? "unknown-message"
const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
return `${messageComponent}:${toolCallComponent}:${variant}`
}
@@ -346,7 +319,36 @@ export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const cacheContext = createMemo(() => ({
toolCallId: toolCallId(),
messageId: props.messageId,
partId: props.toolCall?.id ?? null,
}))
const createVariantCache = (variant: string) =>
useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: TOOL_CALL_CACHE_SCOPE,
key: () => {
const context = cacheContext()
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
},
})
const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown")
const permissionState = createMemo(() => store().getPermissionState(props.messageId, props.toolCall?.id))
const pendingPermission = createMemo(() => {
const state = permissionState()
if (state) {
return { permission: state.entry.permission, active: state.active }
}
return props.toolCall.pendingPermission
})
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
@@ -374,30 +376,49 @@ export default function ToolCall(props: ToolCallProps) {
let scrollContainerRef: HTMLDivElement | undefined
let toolCallRootRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
const id = toolCallId()
if (!id || !scrollContainerRef) return
restoreScrollState(id, scrollContainerRef)
const scrollScopeId = createMemo(() => {
const id = toolCallId()
if (id) return id
const messageKey = props.messageId || "unknown"
const partKey = typeof props.partVersion === "number" ? props.partVersion : 0
return `${messageKey}:${partKey}`
})
const scrollCache = useScrollCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: () => `${TOOL_CALL_CACHE_SCOPE}:scroll:${scrollScopeId()}`,
})
const persistScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
scrollCache.persist(element, { atBottomOffset: 2 })
}
const restoreScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
scrollCache.restore(element, {
fallback: () => {
requestAnimationFrame(() => {
if (!element || !element.isConnected) return
element.scrollTop = element.scrollHeight
persistScrollSnapshot(element)
})
},
})
}
const handleScrollRendered = () => {
if (!scrollContainerRef) return
restoreScrollSnapshot(scrollContainerRef)
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const resolvedElement = element || undefined
scrollContainerRef = resolvedElement
const id = toolCallId()
if (!resolvedElement || !id) return
if (!toolScrollState.has(id)) {
requestAnimationFrame(() => {
if (!scrollContainerRef || toolCallId() !== id) return
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
updateScrollState(id, scrollContainerRef)
})
} else {
restoreScrollState(id, resolvedElement)
}
if (!resolvedElement) return
restoreScrollSnapshot(resolvedElement)
}
createEffect(() => {
@@ -426,16 +447,6 @@ export default function ToolCall(props: ToolCallProps) {
}
})
// Cleanup cache entry when component unmounts or toolCallId changes
createEffect(() => {
const id = toolCallId()
if (!id) return
onCleanup(() => {
toolScrollState.delete(id)
})
})
createEffect(() => {
if (props.toolCall?.tool !== "task") return
const state = props.toolCall?.state
@@ -725,25 +736,20 @@ export default function ToolCall(props: ToolCallProps) {
return renderMarkdownTool(toolName, state)
}
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
function renderDiffTool(payload: DiffPayload, options?: { variant?: string; disableScrollTracking?: boolean; label?: string }) {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light"
// Check if we have valid cache
let cachedHtml: string | undefined
if (cacheKey) {
const cached = getToolRenderCache(cacheKey)
const currentMode = diffMode()
if (cached &&
cached.text === payload.diffText &&
cached.theme === themeKey &&
cached.mode === currentMode) {
cachedHtml = cached.html
}
const cached = cacheHandle.get<RenderCache>()
const currentMode = diffMode()
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
cachedHtml = cached.html
}
const handleModeChange = (mode: DiffViewMode) => {
@@ -751,10 +757,6 @@ export default function ToolCall(props: ToolCallProps) {
}
const handleDiffRendered = () => {
if (cacheKey && !cachedHtml) {
// Cache will be updated by the diff viewer component itself
// We'll capture HTML from the rendered component
}
if (!options?.disableScrollTracking) {
handleScrollRendered()
}
@@ -767,7 +769,7 @@ export default function ToolCall(props: ToolCallProps) {
if (options?.disableScrollTracking) return
initializeScrollContainer(element)
}}
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
@@ -797,7 +799,7 @@ export default function ToolCall(props: ToolCallProps) {
theme={themeKey}
mode={diffMode()}
cachedHtml={cachedHtml}
cacheKey={cacheKey}
cacheEntryParams={cacheHandle.params()}
onRendered={handleDiffRendered}
/>
</div>
@@ -813,20 +815,15 @@ export default function ToolCall(props: ToolCallProps) {
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
const disableHighlight = state?.status === "running"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const markdownPart: TextPart = { type: "text", text: content }
if (cacheKey) {
const cached = getToolRenderCache(cacheKey)
if (cached) {
markdownPart.renderCache = cached
}
const cached = markdownCache.get<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
}
const handleMarkdownRendered = () => {
if (cacheKey) {
setToolRenderCache(cacheKey, markdownPart.renderCache)
}
markdownCache.set(markdownPart.renderCache)
handleScrollRendered()
}
@@ -834,7 +831,7 @@ export default function ToolCall(props: ToolCallProps) {
<div
class={messageClass}
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
>
<Markdown
part={markdownPart}
@@ -1044,7 +1041,7 @@ export default function ToolCall(props: ToolCallProps) {
<div
class="message-text tool-call-markdown tool-call-task-container"
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
>
<div class="tool-call-task-summary">
<For each={summary}>
@@ -1122,7 +1119,7 @@ export default function ToolCall(props: ToolCallProps) {
{(payload) => (
<div class="tool-call-permission-diff">
{renderDiffTool(payload(), {
cacheKeySuffix: "::permission",
variant: "permission-diff",
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})}

View File

@@ -0,0 +1,126 @@
export interface CacheEntryBaseParams {
instanceId?: string
sessionId?: string
scope: string
}
export interface CacheEntryParams extends CacheEntryBaseParams {
key: string
}
type CacheValueMap = Map<string, unknown>
type CacheScopeMap = Map<string, CacheValueMap>
type CacheSessionMap = Map<string, CacheScopeMap>
const GLOBAL_KEY = "GLOBAL"
const cacheStore = new Map<string, CacheSessionMap>()
function resolveKey(value?: string) {
return value && value.length > 0 ? value : GLOBAL_KEY
}
function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
let sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) {
if (!create) return undefined
sessionMap = new Map()
cacheStore.set(instanceKey, sessionMap)
}
let scopeMap = sessionMap.get(sessionKey)
if (!scopeMap) {
if (!create) return undefined
scopeMap = new Map()
sessionMap.set(sessionKey, scopeMap)
}
let valueMap = scopeMap.get(params.scope)
if (!valueMap) {
if (!create) return undefined
valueMap = new Map()
scopeMap.set(params.scope, valueMap)
}
return valueMap
}
function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: string) {
const sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) {
return
}
const scopeMap = sessionMap.get(sessionKey)
if (!scopeMap) {
if (sessionMap.size === 0) {
cacheStore.delete(instanceKey)
}
return
}
if (scopeKey) {
const valueMap = scopeMap.get(scopeKey)
if (valueMap && valueMap.size === 0) {
scopeMap.delete(scopeKey)
}
}
if (scopeMap.size === 0) {
sessionMap.delete(sessionKey)
}
if (sessionMap.size === 0) {
cacheStore.delete(instanceKey)
}
}
export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined): void {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
if (value === undefined) {
const existingMap = getScopeValueMap(params, false)
existingMap?.delete(params.key)
cleanupHierarchy(instanceKey, sessionKey, params.scope)
return
}
const scopeEntries = getScopeValueMap(params, true)
scopeEntries?.set(params.key, value)
}
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
const scopeEntries = getScopeValueMap(params, false)
return scopeEntries?.get(params.key) as T | undefined
}
export function clearCacheScope(params: CacheEntryBaseParams): void {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
const sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) return
const scopeMap = sessionMap.get(sessionKey)
if (!scopeMap) return
scopeMap.delete(params.scope)
cleanupHierarchy(instanceKey, sessionKey)
}
export function clearCacheForSession(instanceId?: string, sessionId?: string): void {
const instanceKey = resolveKey(instanceId)
const sessionKey = resolveKey(sessionId)
const sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) return
sessionMap.delete(sessionKey)
if (sessionMap.size === 0) {
cacheStore.delete(instanceKey)
}
}
export function clearCacheForInstance(instanceId?: string): void {
const instanceKey = resolveKey(instanceId)
cacheStore.delete(instanceKey)
}

View File

@@ -3,6 +3,7 @@ import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
@@ -13,6 +14,8 @@ import {
import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
@@ -21,6 +24,7 @@ export interface UseCommandsOptions {
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
@@ -29,6 +33,18 @@ export interface UseCommandsOptions {
getActiveSessionIdForInstance: () => string | null
}
function extractUserTextFromRecord(record?: MessageRecord): string | null {
if (!record) return null
const parts = record.partIds
.map((partId) => record.parts[partId]?.data)
.filter((part): part is ClientPart => Boolean(part))
const textParts = parts.filter((part): part is ClientPart & { type: "text"; text: string } => part.type === "text" && typeof (part as any).text === "string")
if (textParts.length === 0) {
return null
}
return textParts.map((part) => (part as any).text as string).join("\n")
}
export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([])
@@ -232,30 +248,33 @@ export function useCommands(options: UseCommandsOptions) {
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
let after = 0
const revert = session.revert
const store = messageStoreBus.getOrCreate(instance.id)
const messageIds = store.getSessionMessageIds(sessionId)
const infoMap = new Map<string, MessageInfo>()
messageIds.forEach((id) => {
const info = store.getMessageInfo(id)
if (info) infoMap.set(id, info)
})
if (revert?.messageID) {
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i]
const info = session.messagesInfo.get(msg.id)
if (info?.id === revert.messageID) {
after = info.time?.created || 0
break
}
}
const revertState = store.getSessionRevert(sessionId) ?? session.revert
let after = 0
if (revertState?.messageID) {
const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID)
after = revertInfo?.time?.created || 0
}
let messageID = ""
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i]
const info = session.messagesInfo.get(msg.id)
if (msg.type === "user" && info?.time?.created) {
let restoredText: string | null = null
for (let i = messageIds.length - 1; i >= 0; i--) {
const id = messageIds[i]
const record = store.getMessage(id)
const info = infoMap.get(id) ?? store.getMessageInfo(id)
if (record?.role === "user" && info?.time?.created) {
if (after > 0 && info.time.created >= after) {
continue
}
messageID = msg.id
messageID = id
restoredText = extractUserTextFromRecord(record)
break
}
}
@@ -274,18 +293,17 @@ export function useCommands(options: UseCommandsOptions) {
body: { messageID },
})
const revertedMessage = session.messages.find((m) => m.id === messageID)
const revertedInfo = session.messagesInfo.get(messageID)
if (!restoredText) {
const fallbackRecord = store.getMessage(messageID)
restoredText = extractUserTextFromRecord(fallbackRecord)
}
if (revertedMessage && revertedInfo?.role === "user") {
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
if (textParts.length > 0) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = textParts.map((p: any) => p.text).join("\n")
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} catch (error) {
@@ -368,10 +386,26 @@ export function useCommands(options: UseCommandsOptions) {
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
description: "Show/hide AI thinking process",
category: "System",
keywords: ["/thinking", "toggle", "show", "hide"],
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
return `Thinking Blocks Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle whether thinking blocks start expanded",
category: "System",
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,

View File

@@ -0,0 +1,86 @@
import { type Accessor, createMemo } from "solid-js"
import {
type CacheEntryParams,
getCacheEntry,
setCacheEntry,
clearCacheScope,
clearCacheForSession,
clearCacheForInstance,
} from "../global-cache"
/**
* `useGlobalCache` exposes a tiny typed facade over the shared cache helpers.
* Callers can pass raw values or accessors for the cache keys; empty identifiers
* automatically fall back to the global buckets.
*/
export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle {
const resolvedEntry = createMemo<CacheEntryParams>(() => {
const instanceId = normalizeId(resolveValue(params.instanceId))
const sessionId = normalizeId(resolveValue(params.sessionId))
const scope = resolveValue(params.scope)
const key = resolveValue(params.key)
return { instanceId, sessionId, scope, key }
})
const scopeParams = createMemo(() => {
const entry = resolvedEntry()
return { instanceId: entry.instanceId, sessionId: entry.sessionId, scope: entry.scope }
})
const sessionParams = createMemo(() => {
const entry = resolvedEntry()
return { instanceId: entry.instanceId, sessionId: entry.sessionId }
})
return {
get<T>() {
return getCacheEntry<T>(resolvedEntry())
},
set<T>(value: T | undefined) {
setCacheEntry(resolvedEntry(), value)
},
clearScope() {
clearCacheScope(scopeParams())
},
clearSession() {
const params = sessionParams()
clearCacheForSession(params.instanceId, params.sessionId)
},
clearInstance() {
const params = sessionParams()
clearCacheForInstance(params.instanceId)
},
params() {
return resolvedEntry()
},
}
}
function normalizeId(value?: string): string | undefined {
return value && value.length > 0 ? value : undefined
}
function resolveValue<T>(value: MaybeAccessor<T> | undefined): T {
if (typeof value === "function") {
return (value as Accessor<T>)()
}
return value as T
}
type MaybeAccessor<T> = T | Accessor<T>
interface UseGlobalCacheParams {
instanceId?: MaybeAccessor<string | undefined>
sessionId?: MaybeAccessor<string | undefined>
scope: MaybeAccessor<string>
key: MaybeAccessor<string>
}
interface GlobalCacheHandle {
get<T>(): T | undefined
set<T>(value: T | undefined): void
clearScope(): void
clearSession(): void
clearInstance(): void
params(): CacheEntryParams
}

View File

@@ -0,0 +1,102 @@
import { type Accessor, createMemo } from "solid-js"
import { messageStoreBus } from "../../stores/message-v2/bus"
import type { ScrollSnapshot } from "../../stores/message-v2/types"
interface UseScrollCacheParams {
instanceId: MaybeAccessor<string>
sessionId: MaybeAccessor<string>
scope: MaybeAccessor<string>
}
interface PersistScrollOptions {
atBottomOffset?: number
}
interface RestoreScrollOptions {
behavior?: ScrollBehavior
fallback?: () => void
onApplied?: (snapshot: ScrollSnapshot | undefined) => void
}
interface ScrollCacheHandle {
persist: (element: HTMLElement | null | undefined, options?: PersistScrollOptions) => ScrollSnapshot | undefined
restore: (element: HTMLElement | null | undefined, options?: RestoreScrollOptions) => void
}
const DEFAULT_BOTTOM_OFFSET = 48
/**
* Wraps the message-store scroll snapshot helpers so components can
* persist/restore scroll positions without duplicating requestAnimationFrame
* boilerplate.
*/
export function useScrollCache(params: UseScrollCacheParams): ScrollCacheHandle {
const resolved = createMemo(() => ({
instanceId: resolveValue(params.instanceId),
sessionId: resolveValue(params.sessionId),
scope: resolveValue(params.scope),
}))
const store = createMemo(() => {
const { instanceId } = resolved()
return messageStoreBus.getOrCreate(instanceId)
})
function persist(element: HTMLElement | null | undefined, options?: PersistScrollOptions) {
if (!element) {
return undefined
}
const target = resolved()
if (!target.sessionId) {
return undefined
}
const snapshot: Omit<ScrollSnapshot, "updatedAt"> = {
scrollTop: element.scrollTop,
atBottom: isNearBottom(element, options?.atBottomOffset ?? DEFAULT_BOTTOM_OFFSET),
}
store().setScrollSnapshot(target.sessionId, target.scope, snapshot)
return { ...snapshot, updatedAt: Date.now() }
}
function restore(element: HTMLElement | null | undefined, options?: RestoreScrollOptions) {
const target = resolved()
if (!element || !target.sessionId) {
options?.fallback?.()
options?.onApplied?.(undefined)
return
}
const snapshot = store().getScrollSnapshot(target.sessionId, target.scope)
requestAnimationFrame(() => {
if (!element) {
options?.onApplied?.(snapshot)
return
}
if (!snapshot) {
options?.fallback?.()
options?.onApplied?.(undefined)
return
}
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
const nextTop = snapshot.atBottom ? maxScrollTop : Math.min(snapshot.scrollTop, maxScrollTop)
const behavior = options?.behavior ?? "auto"
element.scrollTo({ top: nextTop, behavior })
options?.onApplied?.(snapshot)
})
}
return { persist, restore }
}
function isNearBottom(element: HTMLElement, offset: number) {
const { scrollTop, scrollHeight, clientHeight } = element
return scrollHeight - (scrollTop + clientHeight) <= offset
}
function resolveValue<T>(value: MaybeAccessor<T>): T {
if (typeof value === "function") {
return (value as Accessor<T>)()
}
return value
}
type MaybeAccessor<T> = T | Accessor<T>

View File

@@ -1,22 +0,0 @@
import type { RenderCache } from "../types/message"
const toolRenderCache = new Map<string, RenderCache>()
export function getToolRenderCache(key?: string | null): RenderCache | undefined {
if (!key) return undefined
return toolRenderCache.get(key)
}
export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void {
if (!key) return
if (cache) {
toolRenderCache.set(key, cache)
} else {
toolRenderCache.delete(key)
}
}
export function clearToolRenderCache(key?: string | null): void {
if (!key) return
toolRenderCache.delete(key)
}

View File

@@ -1,7 +1,8 @@
import { createSignal } from "solid-js"
import { produce } from "solid-js/store"
import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus, Permission } from "@opencode-ai/sdk"
import type { ClientPart, Message } from "../types/message"
import type { ClientPart } from "../types/message"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
@@ -12,14 +13,15 @@ import {
fetchSessions,
fetchAgents,
fetchProviders,
removeSessionIndexes,
clearInstanceDraftPrompts,
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences"
import { computeDisplayParts } from "./session-messages"
import { withSession, setSessionPendingPermission } from "./session-state"
import { setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForInstance } from "../lib/global-cache"
import type { MessageRecord } from "./message-v2/types"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
@@ -294,7 +296,8 @@ function removeInstance(id: string) {
}
// Clean up session indexes and drafts for removed instance
removeSessionIndexes(id)
clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id)
}
@@ -539,23 +542,49 @@ function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
const expectedCallId = permission.callID
for (const part of message.parts) {
if (part.type !== "tool") continue
const toolCallId = (part as any).callID
function getPermissionMessageId(permission: Permission): string | undefined {
return (permission as any).messageID ?? (permission as any).messageId ?? undefined
}
function getPermissionCallIdentifier(permission: Permission): string | undefined {
return (
(permission as any).callID ??
(permission as any).callId ??
(permission as any).toolCallID ??
(permission as any).toolCallId ??
undefined
)
}
function findToolPartForPermission(record: MessageRecord, permission: Permission): { partId: string; part: ClientPart } | null {
const expectedCallId = getPermissionCallIdentifier(permission)
const permissionId = permission.id
const permissionMessageId = getPermissionMessageId(permission)
for (const partId of record.partIds) {
const entry = record.parts[partId]
if (!entry) continue
const part = entry.data
if (!part || part.type !== "tool") continue
const toolCallId = (part as any).callID ?? (part as any).callId
const partMessageId = (part as any).messageID ?? (part as any).messageId
if (expectedCallId) {
if (toolCallId === expectedCallId) {
return part as ClientPart
return { partId, part }
}
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
return part as ClientPart
if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) {
return { partId, part }
}
continue
}
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
return part as ClientPart
if (
(toolCallId && toolCallId === permissionId) ||
part.id === permissionId ||
(permissionMessageId && partMessageId === permissionMessageId)
) {
return { partId, part }
}
}
return null
@@ -564,23 +593,31 @@ function findToolPartForPermission(message: Message, permission: Permission): Cl
function mutateToolPartPermission(
instanceId: string,
permission: Permission,
mutator: (part: ClientPart, message: Message) => boolean,
mutator: (part: ClientPart) => boolean,
): void {
const permissionSessionId = getPermissionSessionId(permission)
withSession(instanceId, permissionSessionId, (session) => {
const message = session.messages.find((msg) => msg.id === permission.messageID)
if (!message) return
const targetPart = findToolPartForPermission(message, permission)
if (!targetPart) return
const messageId = getPermissionMessageId(permission)
if (!messageId) return
const store = messageStoreBus.getOrCreate(instanceId)
const messageRecord = store.getMessage(messageId)
if (!messageRecord) return
const targetPart = findToolPartForPermission(messageRecord, permission)
if (!targetPart) return
const changed = mutator(targetPart, message)
if (!changed) return
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
targetPart.version = nextPartVersion
message.version = (message.version ?? 0) + 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
})
store.setState(
"messages",
messageId,
produce((draft: MessageRecord) => {
const partRecord = draft.parts[targetPart.partId]
if (!partRecord) return
const changed = mutator(partRecord.data)
if (!changed) return
const nextVersion = typeof partRecord.data.version === "number" ? partRecord.data.version + 1 : 1
partRecord.data.version = nextVersion
partRecord.revision += 1
draft.revision += 1
draft.updatedAt = Date.now()
}),
)
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {

View File

@@ -0,0 +1,166 @@
import type { Permission } from "@opencode-ai/sdk"
import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
import type { MessageStatus, SessionRevertState } from "./types"
interface SessionMetadata {
id: string
title?: string
parentId?: string | null
}
function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined {
if (!session) return undefined
return {
id: session.id,
title: session.title,
parentId: session.parentId ?? null,
}
}
function normalizeStatus(status: Message["status"]): MessageStatus {
switch (status) {
case "sending":
case "sent":
case "streaming":
case "complete":
case "error":
return status
default:
return "complete"
}
}
export function seedSessionMessagesV2(
instanceId: string,
session: Session | SessionMetadata,
messages: Message[],
messageInfos?: Map<string, MessageInfo>,
): void {
if (!session || !Array.isArray(messages)) return
const store = messageStoreBus.getOrCreate(instanceId)
const metadata: SessionMetadata = "id" in session ? { id: session.id, title: session.title, parentId: session.parentId ?? null } : session
store.addOrUpdateSession({
id: metadata.id,
title: metadata.title,
parentId: metadata.parentId ?? null,
revert: (session as Session)?.revert ?? undefined,
})
const normalizedMessages = messages.map((message) => ({
id: message.id,
sessionId: message.sessionId,
role: message.type,
status: normalizeStatus(message.status),
createdAt: message.timestamp,
updatedAt: message.timestamp,
parts: message.parts,
isEphemeral: message.status === "sending" || message.status === "streaming",
bumpRevision: false,
}))
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values())
}
interface MessageInfoOptions {
status?: MessageStatus
bumpRevision?: boolean
}
export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null | undefined, options?: MessageInfoOptions): void {
if (!info || typeof info.id !== "string" || typeof info.sessionID !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
store.upsertMessage({
id: info.id,
sessionId: info.sessionID,
role: info.role === "user" ? "user" : "assistant",
status: options?.status ?? "complete",
createdAt,
updatedAt: completedAt ?? createdAt,
bumpRevision: Boolean(options?.bumpRevision),
})
store.setMessageInfo(info.id, info)
}
export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | undefined): void {
if (!part || typeof part.messageID !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
store.applyPartUpdate({
messageId: part.messageID,
part,
})
}
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.replaceMessageId({ oldId, newId })
}
function extractPermissionMessageId(permission: Permission): string | undefined {
return (permission as any).messageID || (permission as any).messageId
}
function extractPermissionPartId(permission: Permission): string | undefined {
const metadata = (permission as any).metadata || {}
return (
(permission as any).callID ||
(permission as any).callId ||
(permission as any).toolCallID ||
(permission as any).toolCallId ||
metadata.partId ||
metadata.partID ||
metadata.callID ||
metadata.callId ||
undefined
)
}
export function upsertPermissionV2(instanceId: string, permission: Permission): void {
if (!permission) return
const store = messageStoreBus.getOrCreate(instanceId)
store.upsertPermission({
permission,
messageId: extractPermissionMessageId(permission),
partId: extractPermissionPartId(permission),
enqueuedAt: (permission as any).time?.created ?? Date.now(),
})
}
export function removePermissionV2(instanceId: string, permissionId: string): void {
if (!permissionId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removePermission(permissionId)
}
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)
const existingMessageIds = store.getSessionMessageIds(session.id)
store.addOrUpdateSession({
id: session.id,
title: session.title,
parentId: session.parentId ?? null,
messageIds: existingMessageIds,
})
}
export function getSessionMetadataFromStore(session?: Session | null): SessionMetadata | undefined {
return resolveSessionMetadata(session ?? undefined)
}
export function setSessionRevertV2(instanceId: string, sessionId: string, revert?: SessionRevertState | null): void {
if (!sessionId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.setSessionRevert(sessionId, revert ?? null)
}

View File

@@ -0,0 +1,64 @@
import { createInstanceMessageStore } from "./instance-store"
import type { InstanceMessageStore } from "./instance-store"
import { clearCacheForInstance } from "../../lib/global-cache"
class MessageStoreBus {
private stores = new Map<string, InstanceMessageStore>()
private teardownHandlers = new Set<(instanceId: string) => void>()
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
if (this.stores.has(instanceId)) {
return this.stores.get(instanceId) as InstanceMessageStore
}
const resolved = store ?? createInstanceMessageStore(instanceId)
this.stores.set(instanceId, resolved)
return resolved
}
getInstance(instanceId: string): InstanceMessageStore | undefined {
return this.stores.get(instanceId)
}
getOrCreate(instanceId: string): InstanceMessageStore {
return this.registerInstance(instanceId)
}
onInstanceDestroyed(handler: (instanceId: string) => void): () => void {
this.teardownHandlers.add(handler)
return () => {
this.teardownHandlers.delete(handler)
}
}
unregisterInstance(instanceId: string) {
const store = this.stores.get(instanceId)
if (store) {
store.clearInstance()
}
clearCacheForInstance(instanceId)
this.notifyInstanceDestroyed(instanceId)
this.stores.delete(instanceId)
}
clearAll() {
for (const [instanceId, store] of this.stores.entries()) {
store.clearInstance()
clearCacheForInstance(instanceId)
this.notifyInstanceDestroyed(instanceId)
this.stores.delete(instanceId)
}
}
private notifyInstanceDestroyed(instanceId: string) {
for (const handler of this.teardownHandlers) {
try {
handler(instanceId)
} catch (error) {
console.error("Failed to run message store teardown handler", error)
}
}
}
}
export const messageStoreBus = new MessageStoreBus()

View File

@@ -0,0 +1,768 @@
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import type { SetStoreFunction } from "solid-js/store"
import type { ClientPart, MessageInfo } from "../../types/message"
import type {
InstanceMessageState,
MessageRecord,
MessageUpsertInput,
PartUpdateInput,
PendingPartEntry,
PermissionEntry,
ReplaceMessageIdOptions,
ScrollSnapshot,
SessionRecord,
SessionUpsertInput,
SessionUsageState,
UsageEntry,
} from "./types"
function createInitialState(instanceId: string): InstanceMessageState {
return {
instanceId,
sessions: {},
sessionOrder: [],
messages: {},
messageInfoVersion: {},
pendingParts: {},
sessionRevisions: {},
permissions: {
queue: [],
active: null,
byMessage: {},
},
usage: {},
scrollState: {},
}
}
function ensurePartId(messageId: string, part: ClientPart, index: number): string {
if (typeof part.id === "string" && part.id.length > 0) {
return part.id
}
return `${messageId}-part-${index}`
}
const PENDING_PART_MAX_AGE_MS = 30_000
function clonePart(part: ClientPart): ClientPart {
if (!part || typeof part !== "object") {
return part
}
const cloned: Record<string, any> = { ...part }
if ("renderCache" in cloned) {
cloned.renderCache = undefined
}
if ("text" in cloned) {
cloned.text = cloneStructuredValue(cloned.text)
}
if ("thinking" in cloned && typeof cloned.thinking === "object") {
cloned.thinking = cloneStructuredValue(cloned.thinking)
}
if ("content" in cloned && Array.isArray(cloned.content)) {
cloned.content = cloneStructuredValue(cloned.content)
}
return cloned as ClientPart
}
function cloneStructuredValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneStructuredValue(item)) as T
}
if (value && typeof value === "object") {
const next: Record<string, any> = {}
Object.entries(value as Record<string, any>).forEach(([key, nested]) => {
next[key] = cloneStructuredValue(nested)
})
return next as T
}
return value
}
function areMessageIdListsEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index++) {
if (a[index] !== b[index]) {
return false
}
}
return true
}
function createEmptyUsageState(): SessionUsageState {
return {
entries: {},
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCost: 0,
actualUsageTokens: 0,
latestMessageId: undefined,
}
}
function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null {
if (!info || info.role !== "assistant") return null
const messageId = typeof info.id === "string" ? info.id : undefined
if (!messageId) return null
const tokens = info.tokens
if (!tokens) return null
const inputTokens = tokens.input ?? 0
const outputTokens = tokens.output ?? 0
const reasoningTokens = tokens.reasoning ?? 0
const cacheReadTokens = tokens.cache?.read ?? 0
const cacheWriteTokens = tokens.cache?.write ?? 0
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
return null
}
const combinedTokens = info.summary ? outputTokens : inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
return {
messageId,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
combinedTokens,
cost: info.cost ?? 0,
timestamp: info.time?.created ?? 0,
hasContextUsage: inputTokens + cacheReadTokens + cacheWriteTokens > 0,
}
}
function applyUsageState(state: SessionUsageState, entry: UsageEntry | null) {
if (!entry) return
state.entries[entry.messageId] = entry
state.totalInputTokens += entry.inputTokens
state.totalOutputTokens += entry.outputTokens
state.totalReasoningTokens += entry.reasoningTokens
state.totalCost += entry.cost
if (!state.latestMessageId || entry.timestamp >= (state.entries[state.latestMessageId]?.timestamp ?? 0)) {
state.latestMessageId = entry.messageId
state.actualUsageTokens = entry.combinedTokens
}
}
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
if (!messageId) return
const existing = state.entries[messageId]
if (!existing) return
state.totalInputTokens -= existing.inputTokens
state.totalOutputTokens -= existing.outputTokens
state.totalReasoningTokens -= existing.reasoningTokens
state.totalCost -= existing.cost
delete state.entries[messageId]
if (state.latestMessageId === messageId) {
state.latestMessageId = undefined
state.actualUsageTokens = 0
let latest: UsageEntry | null = null
for (const candidate of Object.values(state.entries) as UsageEntry[]) {
if (!latest || candidate.timestamp >= latest.timestamp) {
latest = candidate
}
}
if (latest) {
state.latestMessageId = latest.messageId
state.actualUsageTokens = latest.combinedTokens
}
}
}
function rebuildUsageStateFromInfos(infos: Iterable<MessageInfo>): SessionUsageState {
const usageState = createEmptyUsageState()
for (const info of infos) {
const entry = extractUsageEntry(info)
if (entry) {
applyUsageState(usageState, entry)
}
}
return usageState
}
export interface InstanceMessageStore {
instanceId: string
state: InstanceMessageState
setState: SetStoreFunction<InstanceMessageState>
addOrUpdateSession: (input: SessionUpsertInput) => void
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void
bufferPendingPart: (entry: PendingPartEntry) => void
flushPendingParts: (messageId: string) => void
replaceMessageId: (options: ReplaceMessageIdOptions) => void
setMessageInfo: (messageId: string, info: MessageInfo) => void
getMessageInfo: (messageId: string) => MessageInfo | undefined
upsertPermission: (entry: PermissionEntry) => void
removePermission: (permissionId: string) => void
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
getSessionUsage: (sessionId: string) => SessionUsageState | undefined
setScrollSnapshot: (sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) => void
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[]
getMessage: (messageId: string) => MessageRecord | undefined
clearSession: (sessionId: string) => void
clearInstance: () => void
}
export function createInstanceMessageStore(instanceId: string): InstanceMessageStore {
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
const messageInfoCache = new Map<string, MessageInfo>()
function bumpSessionRevision(sessionId: string) {
if (!sessionId) return
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
}
function getSessionRevisionValue(sessionId: string) {
return state.sessionRevisions[sessionId] ?? 0
}
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
setState("usage", sessionId, (current) => {
const draft = current
? {
...current,
entries: { ...current.entries },
}
: createEmptyUsageState()
updater(draft)
return draft
})
}
function updateUsageWithInfo(info: MessageInfo | undefined) {
if (!info || typeof info.sessionID !== "string") return
const messageId = typeof info.id === "string" ? info.id : undefined
if (!messageId) return
withUsageState(info.sessionID, (draft) => {
removeUsageEntry(draft, messageId)
const entry = extractUsageEntry(info)
if (entry) {
applyUsageState(draft, entry)
}
})
}
function rebuildUsage(sessionId: string, infos: Iterable<MessageInfo>) {
const usageState = rebuildUsageStateFromInfos(infos)
setState("usage", sessionId, usageState)
}
function getSessionUsage(sessionId: string) {
return state.usage[sessionId]
}
function ensureSessionEntry(sessionId: string): SessionRecord {
const existing = state.sessions[sessionId]
if (existing) {
return existing
}
const now = Date.now()
const session: SessionRecord = {
id: sessionId,
createdAt: now,
updatedAt: now,
messageIds: [],
}
setState("sessions", sessionId, session)
setState("sessionOrder", (order) => (order.includes(sessionId) ? order : [...order, sessionId]))
return session
}
function addOrUpdateSession(input: SessionUpsertInput) {
const session = ensureSessionEntry(input.id)
const previousIds = [...session.messageIds]
const nextMessageIds = Array.isArray(input.messageIds) ? input.messageIds : session.messageIds
setState("sessions", input.id, {
...session,
title: input.title ?? session.title,
parentId: input.parentId ?? session.parentId ?? null,
updatedAt: Date.now(),
messageIds: nextMessageIds,
revert: input.revert ?? session.revert ?? null,
})
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
bumpSessionRevision(input.id)
}
}
function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) {
if (!Array.isArray(inputs) || inputs.length === 0) return
ensureSessionEntry(sessionId)
const incomingIds = inputs.map((item) => item.id)
const incomingIdSet = new Set(incomingIds)
const existingIds = state.sessions[sessionId]?.messageIds ?? []
const removedIds = existingIds.filter((id) => !incomingIdSet.has(id))
const normalizedRecords: Record<string, MessageRecord> = {}
const now = Date.now()
inputs.forEach((input) => {
const normalizedParts = normalizeParts(input.id, input.parts)
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const previous = state.messages[input.id]
normalizedRecords[input.id] = {
id: input.id,
sessionId: input.sessionId,
role: input.role,
status: input.status,
createdAt: input.createdAt ?? previous?.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false,
revision: previous ? previous.revision + (shouldBump ? 1 : 0) : 0,
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
}
})
const infoList = infos ? Array.from(infos) : undefined
const usageState = infoList ? rebuildUsageStateFromInfos(infoList) : state.usage[sessionId]
const nextMessages: Record<string, MessageRecord> = { ...state.messages }
const nextMessageInfoVersion: Record<string, number> = { ...state.messageInfoVersion }
const nextPendingParts: Record<string, PendingPartEntry[]> = { ...state.pendingParts }
const nextPermissionsByMessage: Record<string, Record<string, PermissionEntry>> = {
...state.permissions.byMessage,
}
removedIds.forEach((id) => {
if (nextMessages[id]?.sessionId === sessionId) {
delete nextMessages[id]
delete nextMessageInfoVersion[id]
delete nextPendingParts[id]
if (nextPermissionsByMessage[id]) {
delete nextPermissionsByMessage[id]
}
}
messageInfoCache.delete(id)
})
Object.entries(normalizedRecords).forEach(([id, record]) => {
nextMessages[id] = record
})
if (infoList) {
for (const info of infoList) {
const messageId = info.id as string
messageInfoCache.set(messageId, info)
const currentVersion = nextMessageInfoVersion[messageId] ?? 0
nextMessageInfoVersion[messageId] = currentVersion + 1
}
}
batch(() => {
setState("messages", () => nextMessages)
setState("messageInfoVersion", () => nextMessageInfoVersion)
setState("pendingParts", () => nextPendingParts)
setState("permissions", "byMessage", () => nextPermissionsByMessage)
if (usageState) {
setState("usage", sessionId, usageState)
}
setState("sessions", sessionId, (session) => ({
...session,
messageIds: incomingIds,
updatedAt: Date.now(),
}))
bumpSessionRevision(sessionId)
})
}
function insertMessageIntoSession(sessionId: string, messageId: string) {
ensureSessionEntry(sessionId)
setState("sessions", sessionId, "messageIds", (ids = []) => {
if (ids.includes(messageId)) {
return ids
}
return [...ids, messageId]
})
}
function normalizeParts(messageId: string, parts: ClientPart[] | undefined) {
if (!parts || parts.length === 0) {
return null
}
const map: MessageRecord["parts"] = {}
const ids: string[] = []
parts.forEach((part, index) => {
const id = ensurePartId(messageId, part, index)
const cloned = clonePart(part)
map[id] = {
id,
data: cloned,
revision: 0,
}
ids.push(id)
})
return { map, ids }
}
function upsertMessage(input: MessageUpsertInput) {
const normalizedParts = normalizeParts(input.id, input.parts)
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now()
setState("messages", input.id, (previous) => {
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
return {
id: input.id,
sessionId: input.sessionId,
role: input.role,
status: input.status,
createdAt: input.createdAt ?? previous?.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false,
revision,
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
}
})
insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id)
bumpSessionRevision(input.sessionId)
}
function bufferPendingPart(entry: PendingPartEntry) {
setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
}
function clearPendingPartsForMessage(messageId: string) {
setState("pendingParts", (prev) => {
if (!prev[messageId]) {
return prev
}
const next = { ...prev }
delete next[messageId]
return next
})
}
function applyPartUpdate(input: PartUpdateInput) {
const message = state.messages[input.messageId]
if (!message) {
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
produce((draft: MessageRecord) => {
if (!draft.partIds.includes(partId)) {
draft.partIds = [...draft.partIds, partId]
}
const existing = draft.parts[partId]
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
draft.parts[partId] = {
id: partId,
data: cloned,
revision: nextRevision,
}
draft.updatedAt = Date.now()
if (input.bumpRevision ?? true) {
draft.revision += 1
}
}),
)
}
function flushPendingParts(messageId: string) {
const pending = state.pendingParts[messageId]
if (!pending || pending.length === 0) {
return
}
const now = Date.now()
const validEntries = pending.filter((entry) => now - entry.receivedAt <= PENDING_PART_MAX_AGE_MS)
if (validEntries.length === 0) {
clearPendingPartsForMessage(messageId)
return
}
validEntries.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
clearPendingPartsForMessage(messageId)
}
function replaceMessageId(options: ReplaceMessageIdOptions) {
if (options.oldId === options.newId) return
const existing = state.messages[options.oldId]
if (!existing) return
const cloned: MessageRecord = {
...existing,
id: options.newId,
isEphemeral: false,
updatedAt: Date.now(),
}
setState("messages", options.newId, cloned)
setState("messages", (prev) => {
const next = { ...prev }
delete next[options.oldId]
return next
})
const affectedSessions = new Set<string>()
Object.values(state.sessions).forEach((session) => {
const index = session.messageIds.indexOf(options.oldId)
if (index === -1) return
setState("sessions", session.id, "messageIds", (ids) => {
const next = [...ids]
next[index] = options.newId
return next
})
affectedSessions.add(session.id)
})
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
const infoEntry = messageInfoCache.get(options.oldId)
if (infoEntry) {
messageInfoCache.set(options.newId, infoEntry)
messageInfoCache.delete(options.oldId)
const version = state.messageInfoVersion[options.oldId] ?? 0
setState("messageInfoVersion", options.newId, version)
setState("messageInfoVersion", (prev) => {
const next = { ...prev }
delete next[options.oldId]
return next
})
}
const permissionMap = state.permissions.byMessage[options.oldId]
if (permissionMap) {
setState("permissions", "byMessage", options.newId, permissionMap)
setState("permissions", (prev) => {
const next = { ...prev }
const nextByMessage = { ...next.byMessage }
delete nextByMessage[options.oldId]
next.byMessage = nextByMessage
return next
})
}
const pending = state.pendingParts[options.oldId]
if (pending) {
setState("pendingParts", options.newId, pending)
}
clearPendingPartsForMessage(options.oldId)
}
function setMessageInfo(messageId: string, info: MessageInfo) {
if (!messageId) return
messageInfoCache.set(messageId, info)
const nextVersion = (state.messageInfoVersion[messageId] ?? 0) + 1
setState("messageInfoVersion", messageId, nextVersion)
updateUsageWithInfo(info)
}
function getMessageInfo(messageId: string) {
void state.messageInfoVersion[messageId]
return messageInfoCache.get(messageId)
}
function upsertPermission(entry: PermissionEntry) {
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? "__global__"
setState(
"permissions",
produce((draft) => {
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
draft.byMessage[messageKey][partKey] = entry
const existingIndex = draft.queue.findIndex((item) => item.permission.id === entry.permission.id)
if (existingIndex === -1) {
draft.queue.push(entry)
} else {
draft.queue[existingIndex] = entry
}
if (!draft.active || draft.active.permission.id === entry.permission.id) {
draft.active = entry
}
}),
)
}
function removePermission(permissionId: string) {
setState(
"permissions",
produce((draft) => {
draft.queue = draft.queue.filter((item) => item.permission.id !== permissionId)
if (draft.active?.permission.id === permissionId) {
draft.active = draft.queue[0] ?? null
}
Object.keys(draft.byMessage).forEach((messageKey) => {
const partEntries = draft.byMessage[messageKey]
Object.keys(partEntries).forEach((partKey) => {
if (partEntries[partKey].permission.id === permissionId) {
delete partEntries[partKey]
}
})
if (Object.keys(partEntries).length === 0) {
delete draft.byMessage[messageKey]
}
})
}),
)
}
function getPermissionState(messageId?: string, partId?: string) {
const messageKey = messageId ?? "__global__"
const partKey = partId ?? "__global__"
const entry = state.permissions.byMessage[messageKey]?.[partKey]
if (!entry) return null
const active = state.permissions.active?.permission.id === entry.permission.id
return { entry, active }
}
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
if (!sessionId) return
ensureSessionEntry(sessionId)
setState("sessions", sessionId, "revert", revert ?? null)
}
function getSessionRevert(sessionId: string) {
return state.sessions[sessionId]?.revert ?? null
}
function makeScrollKey(sessionId: string, scope: string) {
return `${sessionId}:${scope}`
}
function setScrollSnapshot(sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) {
const key = makeScrollKey(sessionId, scope)
setState("scrollState", key, { ...snapshot, updatedAt: Date.now() })
}
function getScrollSnapshot(sessionId: string, scope: string) {
const key = makeScrollKey(sessionId, scope)
return state.scrollState[key]
}
function clearSession(sessionId: string) {
if (!sessionId) return
const messageIds = Object.values(state.messages)
.filter((record) => record.sessionId === sessionId)
.map((record) => record.id)
// Remove message-level data
setState("messages", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => delete next[id])
return next
})
setState("messageInfoVersion", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => delete next[id])
return next
})
messageIds.forEach((id) => messageInfoCache.delete(id))
setState("pendingParts", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
setState("permissions", "byMessage", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
// Remove session-level data
setState("usage", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("sessionRevisions", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("scrollState", (prev) => {
const next = { ...prev }
const prefix = `${sessionId}:`
Object.keys(next).forEach((key) => {
if (key.startsWith(prefix)) {
delete next[key]
}
})
return next
})
setState("sessions", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
}
function clearInstance() {
messageInfoCache.clear()
setState(reconcile(createInitialState(instanceId)))
}
return {
instanceId,
state,
setState,
addOrUpdateSession,
hydrateMessages,
upsertMessage,
applyPartUpdate,
bufferPendingPart,
flushPendingParts,
replaceMessageId,
setMessageInfo,
getMessageInfo,
upsertPermission,
removePermission,
getPermissionState,
setSessionRevert,
getSessionRevert,
rebuildUsage,
getSessionUsage,
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getMessage: (messageId: string) => state.messages[messageId],
clearSession,
clearInstance,
}
}

View File

@@ -0,0 +1,74 @@
import { decodeHtmlEntities } from "../../lib/markdown"
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
return decodeHtmlEntities(segment)
}
if (segment && typeof segment === "object") {
const updated: Record<string, any> = { ...segment }
if (typeof updated.text === "string") {
updated.text = decodeHtmlEntities(updated.text)
}
if (typeof updated.value === "string") {
updated.value = decodeHtmlEntities(updated.value)
}
if (Array.isArray(updated.content)) {
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
}
return updated
}
return segment
}
export function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
if (part.type !== "text") {
return part
}
const normalized: Record<string, any> = { ...part, renderCache: undefined }
if (typeof normalized.text === "string") {
normalized.text = decodeHtmlEntities(normalized.text)
} else if (normalized.text && typeof normalized.text === "object") {
const textObject: Record<string, any> = { ...normalized.text }
if (typeof textObject.value === "string") {
textObject.value = decodeHtmlEntities(textObject.value)
}
if (Array.isArray(textObject.content)) {
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
}
if (typeof textObject.text === "string") {
textObject.text = decodeHtmlEntities(textObject.text)
}
normalized.text = textObject
}
if (Array.isArray(normalized.content)) {
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
}
if (normalized.thinking && typeof normalized.thinking === "object") {
const thinking: Record<string, any> = { ...normalized.thinking }
if (Array.isArray(thinking.content)) {
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
}
normalized.thinking = thinking
}
return normalized
}

View File

@@ -0,0 +1,46 @@
import type { ClientPart } from "../../types/message"
import type { MessageRecord } from "./types"
export interface RecordDisplayData {
orderedParts: ClientPart[]
}
interface RecordDisplayCacheEntry {
revision: number
data: RecordDisplayData
}
const recordDisplayCache = new Map<string, RecordDisplayCacheEntry>()
function makeCacheKey(instanceId: string, messageId: string) {
return `${instanceId}:${messageId}`
}
export function buildRecordDisplayData(instanceId: string, record: MessageRecord): RecordDisplayData {
const cacheKey = makeCacheKey(instanceId, record.id)
const cached = recordDisplayCache.get(cacheKey)
if (cached && cached.revision === record.revision) {
return cached.data
}
const orderedParts: ClientPart[] = []
for (const partId of record.partIds) {
const entry = record.parts[partId]
if (!entry?.data) continue
orderedParts.push(entry.data)
}
const data: RecordDisplayData = { orderedParts }
recordDisplayCache.set(cacheKey, { revision: record.revision, data })
return data
}
export function clearRecordDisplayCacheForInstance(instanceId: string) {
const prefix = `${instanceId}:`
for (const key of recordDisplayCache.keys()) {
if (key.startsWith(prefix)) {
recordDisplayCache.delete(key)
}
}
}

View File

@@ -0,0 +1,139 @@
import type { Provider } from "../../types/session"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "../session-models"
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "../session-state"
import { messageStoreBus } from "./bus"
import type { SessionUsageState } from "./types"
function getLatestUsageEntry(usage?: SessionUsageState) {
if (!usage?.latestMessageId) return undefined
return usage.entries[usage.latestMessageId]
}
function resolveSelectedModel(instanceProviders: Provider[], providerId?: string, modelId?: string) {
if (!providerId || !modelId) return undefined
const provider = instanceProviders.find((p) => p.id === providerId)
return provider?.models.find((m) => m.id === modelId)
}
export function updateSessionInfo(instanceId: string, sessionId: string): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)
const usage = store.getSessionUsage(sessionId)
const hasUsageEntries = Boolean(usage && Object.keys(usage.entries).length > 0)
let totalInputTokens = usage?.totalInputTokens ?? 0
let totalOutputTokens = usage?.totalOutputTokens ?? 0
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
let totalCost = usage?.totalCost ?? 0
let actualUsageTokens = usage?.actualUsageTokens ?? 0
const latestEntry = getLatestUsageEntry(usage)
let latestHasContextUsage = latestEntry?.hasContextUsage ?? false
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
let contextWindow = 0
let contextAvailableTokens: number | null = null
let contextAvailableFromPrevious = false
let isSubscriptionModel = false
if (!hasUsageEntries && previousInfo) {
totalInputTokens = previousInfo.inputTokens
totalOutputTokens = previousInfo.outputTokens
totalReasoningTokens = previousInfo.reasoningTokens
totalCost = previousInfo.cost
actualUsageTokens = previousInfo.actualUsageTokens
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
const sessionProviderId = sessionModel?.providerId
const sessionModelId = sessionModel?.modelId
const latestInfo = latestEntry?.messageId ? store.getMessageInfo(latestEntry.messageId) : undefined
const latestProviderId = (latestInfo as any)?.providerID || (latestInfo as any)?.providerId || ""
const latestModelId = (latestInfo as any)?.modelID || (latestInfo as any)?.modelId || ""
const selectedModel =
resolveSelectedModel(instanceProviders, sessionProviderId, sessionModelId) ??
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
if (selectedModel) {
contextWindow = selectedModel.limit?.context ?? 0
const outputLimit = selectedModel.limit?.output
if (typeof outputLimit === "number" && outputLimit > 0) {
modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
}
if ((selectedModel.cost?.input ?? 0) === 0 && (selectedModel.cost?.output ?? 0) === 0) {
isSubscriptionModel = true
}
}
if (contextWindow === 0 && previousInfo) {
contextWindow = previousInfo.contextWindow
}
modelOutputLimit = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
if (previousInfo) {
const previousContextWindow = previousInfo.contextWindow
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
const previousHasContextUsage = previousContextAvailable !== null && previousContextWindow > 0
? previousContextAvailable < previousContextWindow
: false
if (contextWindow !== previousContextWindow) {
contextAvailableTokens = null
contextAvailableFromPrevious = false
latestHasContextUsage = previousHasContextUsage
} else {
contextAvailableTokens = previousContextAvailable
contextAvailableFromPrevious = true
latestHasContextUsage = previousHasContextUsage
}
if (!hasUsageEntries) {
isSubscriptionModel = previousInfo.isSubscriptionModel
} else if (!isSubscriptionModel) {
isSubscriptionModel = previousInfo.isSubscriptionModel
}
}
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
if (!contextAvailableFromPrevious) {
if (contextWindow > 0) {
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextAvailableTokens = null
}
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
cost: totalCost,
contextWindow,
isSubscriptionModel,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
actualUsageTokens,
modelOutputLimit,
contextAvailableTokens,
})
next.set(instanceId, instanceInfo)
return next
})
}

View File

@@ -0,0 +1,139 @@
import type { ClientPart } from "../../types/message"
import type { Permission } from "@opencode-ai/sdk"
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
export type MessageRole = "user" | "assistant"
export interface NormalizedPartRecord {
id: string
data: ClientPart
revision: number
}
export interface MessageRecord {
id: string
sessionId: string
role: MessageRole
status: MessageStatus
createdAt: number
updatedAt: number
revision: number
isEphemeral?: boolean
partIds: string[]
parts: Record<string, NormalizedPartRecord>
}
export interface SessionRevertState {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
export interface SessionRecord {
id: string
title?: string
parentId?: string | null
createdAt: number
updatedAt: number
messageIds: string[]
revert?: SessionRevertState | null
}
export interface PendingPartEntry {
messageId: string
part: ClientPart
receivedAt: number
}
export interface PermissionEntry {
permission: Permission
messageId?: string
partId?: string
enqueuedAt: number
}
export interface InstancePermissionState {
queue: PermissionEntry[]
active: PermissionEntry | null
byMessage: Record<string, Record<string, PermissionEntry>>
}
export interface ScrollSnapshot {
scrollTop: number
atBottom: boolean
updatedAt: number
}
export interface UsageEntry {
messageId: string
inputTokens: number
outputTokens: number
reasoningTokens: number
cacheReadTokens: number
cacheWriteTokens: number
combinedTokens: number
cost: number
timestamp: number
hasContextUsage: boolean
}
export interface SessionUsageState {
entries: Record<string, UsageEntry>
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCost: number
actualUsageTokens: number
latestMessageId?: string
}
export interface InstanceMessageState {
instanceId: string
sessions: Record<string, SessionRecord>
sessionOrder: string[]
messages: Record<string, MessageRecord>
messageInfoVersion: Record<string, number>
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>
permissions: InstancePermissionState
usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot>
}
export interface SessionUpsertInput {
id: string
title?: string
parentId?: string | null
messageIds?: string[]
revert?: SessionRevertState | null
}
export interface MessageUpsertInput {
id: string
sessionId: string
role: MessageRole
status: MessageStatus
parts?: ClientPart[]
createdAt?: number
updatedAt?: number
isEphemeral?: boolean
bumpRevision?: boolean
}
export interface PartUpdateInput {
messageId: string
part: ClientPart
bumpRevision?: boolean
}
export interface ReplaceMessageIdOptions {
oldId: string
newId: string
}
export interface ScrollCacheKey {
instanceId: string
sessionId: string
scope: string
}

View File

@@ -29,6 +29,7 @@ export type ExpansionPreference = "expanded" | "collapsed"
export interface Preferences {
showThinkingBlocks: boolean
thinkingBlocksExpansion: ExpansionPreference
lastUsedBinary?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
@@ -56,6 +57,7 @@ const MAX_RECENT_MODELS = 5
const defaultPreferences: Preferences = {
showThinkingBlocks: false,
thinkingBlocksExpansion: "expanded",
environmentVariables: {},
modelRecents: [],
diffViewMode: "split",
@@ -88,6 +90,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables,
modelRecents,
@@ -269,6 +272,11 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void {
updatePreferences({ diagnosticsExpansion: mode })
}
function setThinkingBlocksExpansion(mode: ExpansionPreference): void {
if (preferences().thinkingBlocksExpansion === mode) return
updatePreferences({ thinkingBlocksExpansion: mode })
}
function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
@@ -381,6 +389,7 @@ interface ConfigContextValue {
setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary
@@ -412,6 +421,7 @@ const configContextValue: ConfigContextValue = {
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
@@ -480,7 +490,9 @@ export {
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
themePreference,
setThemePreference,
recordWorkspaceLaunch,
}
}

View File

@@ -1,21 +1,11 @@
import type { Message } from "../types/message"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import {
addRecentModelPreference,
preferences,
setAgentModelPreference,
} from "./preferences"
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
import { sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
getSessionIndex,
initializePartVersion,
updateSessionInfo,
} from "./session-messages"
import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
const ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -93,26 +83,6 @@ async function sendMessage(
},
]
const optimisticMessage: Message = {
id: messageId,
sessionId,
type: "user",
parts: optimisticParts,
timestamp: Date.now(),
status: "sending",
version: 0,
}
optimisticParts.forEach((part: any) => initializePartVersion(part))
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
withSession(instanceId, sessionId, (session) => {
session.messages.push(optimisticMessage)
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
})
const requestParts: any[] = [
{
id: textPartId,
@@ -167,6 +137,24 @@ async function sendMessage(
}
}
const store = messageStoreBus.getOrCreate(instanceId)
const createdAt = Date.now()
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "sending",
parts: optimisticParts,
createdAt,
updatedAt: createdAt,
isEphemeral: true,
})
withSession(instanceId, sessionId, () => {
/* trigger reactivity for legacy session data */
})
const requestBody = {
messageID: messageId,
parts: requestParts,

View File

@@ -2,7 +2,7 @@ import type { Session } from "../types/session"
import type { Message } from "../types/message"
import { instances, refreshPermissionsForSession } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
activeSessionId,
@@ -22,16 +22,11 @@ import {
setLoading,
} from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
clearSessionIndex,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
updateSessionInfo,
} from "./session-messages"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
interface SessionForkResponse {
id: string
@@ -100,8 +95,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
diff: apiSession.revert.diff,
}
: undefined,
messages: existingSession?.messages ?? [],
messagesInfo: existingSession?.messagesInfo ?? new Map(),
})
}
@@ -196,8 +189,6 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
diff: response.data.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
@@ -237,8 +228,6 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
return next
})
getSessionIndex(instanceId, session.id)
return session
} catch (error) {
console.error("Failed to create session:", error)
@@ -301,8 +290,6 @@ async function forkSession(
diff: info.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
} as unknown as Session
setSessions((prev) => {
@@ -340,8 +327,6 @@ async function forkSession(
return next
})
getSessionIndex(instanceId, forkedSession.id)
return forkedSession
}
@@ -375,6 +360,10 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
setSessionCompactionState(instanceId, sessionId, false)
clearSessionDraftPrompt(instanceId, sessionId)
// Drop normalized message state and caches for this session
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
clearCacheForSession(instanceId, sessionId)
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = next.get(instanceId)
@@ -390,8 +379,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
return next
})
clearSessionIndex(instanceId, sessionId)
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
@@ -521,8 +508,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
})
try {
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId })
const response = await instance.client.session.messages({ path: { id: sessionId } })
console.log(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
if (!response.data || !Array.isArray(response.data)) {
return
@@ -548,10 +535,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
version: 0,
}
parts.forEach((part: any) => initializePartVersion(part))
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
return message
})
@@ -586,8 +569,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
if (existingSession) {
const updatedSession = {
...existingSession,
messages,
messagesInfo,
agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
}
@@ -599,9 +580,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next
})
rebuildSessionIndex(instanceId, sessionId, messages)
rebuildSessionUsage(instanceId, sessionId, messagesInfo)
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
@@ -610,6 +588,14 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next
})
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
id: sessionId,
title: session?.title,
parentId: session?.parentId ?? null,
revert: session?.revert,
}
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
} catch (error) {
console.error("Failed to load messages:", error)
throw error

View File

@@ -1,4 +1,5 @@
import type {
MessageInfo,
MessagePartRemovedEvent,
MessagePartUpdatedEvent,
MessageRemovedEvent,
@@ -12,9 +13,9 @@ import type {
EventSessionIdle,
EventSessionUpdated,
} from "@opencode-ai/sdk"
import type { MessageStatus } from "./message-v2/types"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences } from "./preferences"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import { showAlertDialog } from "./alerts"
import {
@@ -22,18 +23,20 @@ import {
setSessions,
withSession,
} from "./session-state"
import {
bumpPartVersion,
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
updateUsageFromMessageInfo,
} from "./session-messages"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
import {
applyPartUpdateV2,
replaceMessageIdV2,
upsertMessageInfoV2,
upsertPermissionV2,
removePermissionV2,
setSessionRevertV2,
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
interface TuiToastEvent {
type: "tui.toast.show"
@@ -47,6 +50,30 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
type MessageRole = "user" | "assistant"
function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant"
}
function findPendingMessageId(
store: InstanceMessageStore,
sessionId: string,
role: MessageRole,
): string | undefined {
const messageIds = store.getSessionMessageIds(sessionId)
for (let i = messageIds.length - 1; i >= 0; i -= 1) {
const record = store.getMessage(messageIds[i])
if (!record) continue
if (record.sessionId !== sessionId) continue
if (record.role !== role) continue
if (record.status === "sending") {
return record.id
}
}
return undefined
}
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
@@ -56,265 +83,91 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined
const messageId = typeof part.messageID === "string" ? part.messageID : undefined
if (!sessionId || !messageId) return
const session = instanceSessions.get(part.sessionID)
const session = instanceSessions.get(sessionId)
if (!session) return
const index = getSessionIndex(instanceId, part.sessionID)
let messageIndex = index.messageIndex.get(part.messageID)
let replacedTemp = false
const store = messageStoreBus.getOrCreate(instanceId)
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
if (messageIndex === undefined) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === part.sessionID && msg.status === "sending") {
messageIndex = i
replacedTemp = true
break
}
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
record = store.getMessage(messageId)
}
}
if (messageIndex === undefined) {
const newMessage: any = {
id: part.messageID,
sessionId: part.sessionID,
type: "assistant" as const,
parts: [part],
timestamp: Date.now(),
status: "streaming" as const,
version: 0,
}
initializePartVersion(part)
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
} else {
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
let filteredSynthetics = false
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
filteredSynthetics = true
message.parts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
}
let baseParts: any[]
if (replacedTemp) {
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
message.parts = baseParts
baseParts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
} else {
baseParts = message.parts
}
let partMap = index.partIndex.get(message.id)
if (!partMap) {
partMap = new Map()
index.partIndex.set(message.id, partMap)
}
let shouldIncrementVersion = filteredSynthetics || replacedTemp
const partIndex = partMap.get(part.id)
if (partIndex === undefined) {
initializePartVersion(part)
baseParts.push(part)
if (part.id && typeof part.id === "string") {
partMap.set(part.id, baseParts.length - 1)
}
shouldIncrementVersion = true
if (part.type === "text") {
part.renderCache = undefined
}
} else {
const previousPart = baseParts[partIndex]
const textUnchanged =
!filteredSynthetics &&
!replacedTemp &&
part.type === "text" &&
previousPart?.type === "text" &&
previousPart.text === part.text
if (textUnchanged) {
return
}
bumpPartVersion(previousPart, part)
baseParts[partIndex] = part
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
shouldIncrementVersion = true
if (part.type === "text") {
part.renderCache = undefined
}
}
}
const oldId = message.id
message.id = replacedTemp ? part.messageID : message.id
message.status = message.status === "sending" ? "streaming" : message.status
message.parts = baseParts
if (shouldIncrementVersion) {
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
} else if (
!message.displayParts ||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
message.displayParts.version !== message.version
) {
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, messageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
if (filteredSynthetics || replacedTemp) {
const refreshed = new Map<string, number>()
message.parts.forEach((partItem, idx) => {
if (partItem.id && typeof partItem.id === "string") {
refreshed.set(partItem.id, idx)
}
})
index.partIndex.set(message.id, refreshed)
}
if (!record) {
store.upsertMessage({
id: messageId,
sessionId,
role,
status: "streaming",
createdAt,
updatedAt: createdAt,
isEphemeral: true,
})
}
withSession(instanceId, part.sessionID, () => {
/* mutations already applied above */
})
if (messageInfo) {
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}
updateSessionInfo(instanceId, part.sessionID)
refreshPermissionsForSession(instanceId, part.sessionID)
applyPartUpdateV2(instanceId, part)
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
const session = instanceSessions.get(info.sessionID)
const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
const index = getSessionIndex(instanceId, info.sessionID)
let messageIndex = index.messageIndex.get(info.id)
const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete"
if (messageIndex === undefined) {
let tempMessageIndex = -1
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (
msg.sessionId === info.sessionID &&
msg.type === (info.role === "user" ? "user" : "assistant") &&
msg.status === "sending"
) {
tempMessageIndex = i
break
}
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
record = store.getMessage(messageId)
}
if (tempMessageIndex === -1) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === info.sessionID && msg.status === "sending") {
tempMessageIndex = i
break
}
}
}
if (tempMessageIndex > -1) {
const message = session.messages[tempMessageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
const oldId = message.id
message.id = info.id
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
message.timestamp = info.time?.created || Date.now()
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, tempMessageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
} else {
const newMessage: any = {
id: info.id,
sessionId: info.sessionID,
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
parts: [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
}
} else {
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
session.messagesInfo.set(info.id, info)
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
withSession(instanceId, info.sessionID, () => {
/* ensure reactivity */
})
if (!record) {
const createdAt = info.time?.created ?? Date.now()
const completedAt = (info.time as { completed?: number } | undefined)?.completed
store.upsertMessage({
id: messageId,
sessionId,
role,
status,
createdAt,
updatedAt: completedAt ?? createdAt,
})
}
updateSessionInfo(instanceId, info.sessionID)
refreshPermissionsForSession(instanceId, info.sessionID)
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
}
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
@@ -348,8 +201,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
created: Date.now(),
updated: Date.now(),
},
messages: [],
messagesInfo: new Map(),
} as any
setSessions((prev) => {
@@ -359,6 +210,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
next.set(instanceId, updated)
return next
})
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
console.log(`[SSE] New session created: ${info.id}`, newSession)
} else {
@@ -391,6 +243,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
next.set(instanceId, updated)
return next
})
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
}
}
@@ -490,6 +343,7 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
}
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
@@ -498,6 +352,7 @@ function handlePermissionReplied(instanceId: string, event: EventPermissionRepli
console.log(`[SSE] Permission replied: ${permissionID}`)
removePermissionFromQueue(instanceId, permissionID)
removePermissionV2(instanceId, permissionID)
}
export {

View File

@@ -1,462 +0,0 @@
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText, type MessageInfo } from "../types/message"
import type { Provider } from "../types/session"
import { decodeHtmlEntities } from "../lib/markdown"
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
interface SessionIndexCache {
messageIndex: Map<string, number>
partIndex: Map<string, Map<string, number>>
}
interface AssistantUsageEntry {
info: MessageInfo
inputTokens: number
outputTokens: number
reasoningTokens: number
combinedTokens: number
cost: number
hasContextUsage: boolean
timestamp: number
}
interface SessionUsageState {
entries: Map<string, AssistantUsageEntry>
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCost: number
latestEntry: AssistantUsageEntry | null
}
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
function createEmptyUsageState(): SessionUsageState {
return {
entries: new Map(),
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCost: 0,
latestEntry: null,
}
}
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
let usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) {
usageMap = new Map()
sessionUsageStates.set(instanceId, usageMap)
}
return usageMap
}
function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState {
const usageMap = getUsageInstance(instanceId)
let state = usageMap.get(sessionId)
if (!state) {
state = createEmptyUsageState()
usageMap.set(sessionId, state)
}
return state
}
function recomputeLatestEntry(state: SessionUsageState) {
state.latestEntry = null
for (const entry of state.entries.values()) {
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
}
function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null {
if (!info || info.role !== "assistant") return null
if (!info.tokens) return null
const tokens = info.tokens
const inputTokens = tokens.input ?? 0
const outputTokens = tokens.output ?? 0
const reasoningTokens = tokens.reasoning ?? 0
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) {
return null
}
const cacheReadTokens = tokens.cache?.read ?? 0
const cacheWriteTokens = tokens.cache?.write ?? 0
const combinedTokens = info.summary
? outputTokens
: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
const cost = info.cost ?? 0
const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
return {
info,
inputTokens,
outputTokens,
reasoningTokens,
combinedTokens,
cost,
hasContextUsage,
timestamp: info.time?.created ?? 0,
}
}
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
if (!messageId) return
const existing = state.entries.get(messageId)
if (!existing) return
state.entries.delete(messageId)
state.totalInputTokens -= existing.inputTokens
state.totalOutputTokens -= existing.outputTokens
state.totalReasoningTokens -= existing.reasoningTokens
state.totalCost -= existing.cost
if (state.latestEntry?.info.id === messageId) {
recomputeLatestEntry(state)
}
}
function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) {
state.entries.set(entry.info.id, entry)
state.totalInputTokens += entry.inputTokens
state.totalOutputTokens += entry.outputTokens
state.totalReasoningTokens += entry.reasoningTokens
state.totalCost += entry.cost
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) {
const messageId = typeof info.id === "string" ? info.id : undefined
if (!messageId) return
const state = getSessionUsageState(instanceId, sessionId)
removeUsageEntry(state, messageId)
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(state, entry)
}
}
function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map<string, MessageInfo>) {
const usageMap = getUsageInstance(instanceId)
const nextState = createEmptyUsageState()
for (const info of messagesInfo.values()) {
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(nextState, entry)
}
}
usageMap.set(sessionId, nextState)
}
function clearSessionUsage(instanceId: string, sessionId: string) {
const usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) return
usageMap.delete(sessionId)
if (usageMap.size === 0) {
sessionUsageStates.delete(instanceId)
}
}
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
return decodeHtmlEntities(segment)
}
if (segment && typeof segment === "object") {
const updated: Record<string, any> = { ...segment }
if (typeof updated.text === "string") {
updated.text = decodeHtmlEntities(updated.text)
}
if (typeof updated.value === "string") {
updated.value = decodeHtmlEntities(updated.value)
}
if (Array.isArray(updated.content)) {
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
}
return updated
}
return segment
}
function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
if (part.type !== "text") {
return part
}
const normalized: Record<string, any> = { ...part, renderCache: undefined }
if (typeof normalized.text === "string") {
normalized.text = decodeHtmlEntities(normalized.text)
} else if (normalized.text && typeof normalized.text === "object") {
const textObject: Record<string, any> = { ...normalized.text }
if (typeof textObject.value === "string") {
textObject.value = decodeHtmlEntities(textObject.value)
}
if (Array.isArray(textObject.content)) {
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
}
if (typeof textObject.text === "string") {
textObject.text = decodeHtmlEntities(textObject.text)
}
normalized.text = textObject
}
if (Array.isArray(normalized.content)) {
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
}
if (normalized.thinking && typeof normalized.thinking === "object") {
const thinking: Record<string, any> = { ...normalized.thinking }
if (Array.isArray(thinking.content)) {
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
}
normalized.thinking = thinking
}
return normalized
}
function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
const text: any[] = []
const tool: any[] = []
const reasoning: any[] = []
for (const part of message.parts) {
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
text.push(part)
} else if (part.type === "tool") {
tool.push(part)
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
reasoning.push(part)
}
}
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
const version = typeof message.version === "number" ? message.version : 0
return { text, tool, reasoning, combined, showThinking, version }
}
function initializePartVersion(part: any, version = 0) {
if (!part || typeof part !== "object") return
const partAny = part as any
if (typeof partAny.version !== "number") {
partAny.version = version
}
}
function bumpPartVersion(previousPart: any, nextPart: any): number {
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
const nextVersion = prevVersion + 1
nextPart.version = nextVersion
return nextVersion
}
function getSessionIndex(instanceId: string, sessionId: string) {
let instanceMap = sessionIndexes.get(instanceId)
if (!instanceMap) {
instanceMap = new Map()
sessionIndexes.set(instanceId, instanceMap)
}
let sessionMap = instanceMap.get(sessionId)
if (!sessionMap) {
sessionMap = { messageIndex: new Map(), partIndex: new Map() }
instanceMap.set(sessionId, sessionMap)
}
return sessionMap
}
function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) {
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.clear()
index.partIndex.clear()
messages.forEach((message, messageIdx) => {
index.messageIndex.set(message.id, messageIdx)
const partMap = new Map<string, number>()
message.parts.forEach((part, partIdx) => {
if (part.id && typeof part.id === "string") {
partMap.set(part.id, partIdx)
}
})
index.partIndex.set(message.id, partMap)
})
}
function clearSessionIndex(instanceId: string, sessionId: string) {
const instanceMap = sessionIndexes.get(instanceId)
if (instanceMap) {
instanceMap.delete(sessionId)
if (instanceMap.size === 0) {
sessionIndexes.delete(instanceId)
}
}
clearSessionUsage(instanceId, sessionId)
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.delete(instanceId)
sessionUsageStates.delete(instanceId)
}
function updateSessionInfo(instanceId: string, sessionId: string) {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let actualUsageTokens = 0
const usageState = getSessionUsageState(instanceId, sessionId)
const hasUsageEntries = usageState.entries.size > 0
let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0
let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0
let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0
let totalCost = hasUsageEntries ? usageState.totalCost : 0
let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null
let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
let contextAvailableTokens: number | null = null
let contextAvailableFromPrevious = false
if (latestAssistantInfo) {
const infoAny = latestAssistantInfo as any
actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0
modelID = infoAny.modelID || ""
providerID = infoAny.providerID || ""
} else if (previousInfo) {
totalInputTokens = previousInfo.inputTokens
totalOutputTokens = previousInfo.outputTokens
totalReasoningTokens = previousInfo.reasoningTokens
totalCost = previousInfo.cost
actualUsageTokens = previousInfo.actualUsageTokens
const previousContextWindow = previousInfo.contextWindow
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
const previousHasContextUsage =
previousContextAvailable !== null && previousContextWindow > 0
? previousContextAvailable < previousContextWindow
: false
if (contextWindow === 0) {
contextWindow = previousContextWindow
}
if (contextWindow !== previousContextWindow) {
contextAvailableTokens = null
contextAvailableFromPrevious = false
latestHasContextUsage = previousHasContextUsage
} else {
contextAvailableTokens = previousContextAvailable
contextAvailableFromPrevious = true
latestHasContextUsage = previousHasContextUsage
}
isSubscriptionModel = previousInfo.isSubscriptionModel
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
let selectedModel: Provider["models"][number] | undefined
if (sessionModel?.providerId && sessionModel?.modelId) {
const provider = instanceProviders.find((p) => p.id === sessionModel.providerId)
selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId)
}
if (!selectedModel && modelID && providerID) {
const provider = instanceProviders.find((p) => p.id === providerID)
selectedModel = provider?.models.find((m) => m.id === modelID)
}
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
if (selectedModel) {
if (selectedModel.limit?.context) {
contextWindow = selectedModel.limit.context
}
if (selectedModel.limit?.output && selectedModel.limit.output > 0) {
modelOutputLimit = selectedModel.limit.output
}
if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) {
isSubscriptionModel = true
}
}
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
if (!contextAvailableFromPrevious) {
if (contextWindow > 0) {
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextAvailableTokens = null
}
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
cost: totalCost,
contextWindow,
isSubscriptionModel,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
actualUsageTokens,
modelOutputLimit,
contextAvailableTokens,
})
next.set(instanceId, instanceInfo)
return next
})
}
export {
bumpPartVersion,
clearSessionIndex,
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
removeSessionIndexes,
updateSessionInfo,
updateUsageFromMessageInfo,
}

View File

@@ -109,8 +109,6 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
const updatedSession = {
...session,
messages: [...session.messages],
messagesInfo: new Map(session.messagesInfo),
}
setSessions((prev) => {

View File

@@ -1,7 +1,9 @@
import type { Session, SessionStatus } from "../types/session"
import type { Message, MessageInfo } from "../types/message"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId)
@@ -17,36 +19,13 @@ function isSessionCompacting(session: Session): boolean {
return Boolean(compactingFlag)
}
function getMessageTimestamp(session: Session, message?: Message): number {
if (!message) return Number.NEGATIVE_INFINITY
if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
return message.timestamp
}
const info = session.messagesInfo.get(message.id)
return info?.time?.created ?? Number.NEGATIVE_INFINITY
}
function getLastMessage(session: Session): Message | undefined {
let latest: Message | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const message of session.messages) {
if (!message) continue
const timestamp = getMessageTimestamp(session, message)
if (timestamp >= latestTimestamp) {
latest = message
latestTimestamp = timestamp
}
}
return latest
}
function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
if (session.messagesInfo.size === 0) {
return undefined
}
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
let latest: MessageInfo | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const info of session.messagesInfo.values()) {
for (const id of messageIds) {
const info = store.getMessageInfo(id)
if (!info) continue
if (role && info.role !== role) continue
const timestamp = info.time?.created ?? 0
@@ -58,6 +37,25 @@ function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): Messa
return latest
}
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
let latest: MessageRecord | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const id of messageIds) {
const record = store.getMessage(id)
if (!record) continue
const info = store.getMessageInfo(id)
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
if (timestamp >= latestTimestamp) {
latest = record
latestTimestamp = timestamp
}
}
return latest
}
function getInfoCreatedTimestamp(info?: MessageInfo): number {
if (!info) {
return Number.NEGATIVE_INFINITY
@@ -92,16 +90,16 @@ function isAssistantInfoPending(info?: MessageInfo): boolean {
return completed < created
}
function isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean {
if (message.type !== "assistant") {
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
if (record.role !== "assistant") {
return false
}
if (message.status === "error") {
if (record.status === "error") {
return false
}
if (message.status === "streaming" || message.status === "sending") {
if (record.status === "streaming" || record.status === "sending") {
return true
}
@@ -110,24 +108,29 @@ function isAssistantStillGenerating(message: Message, info?: MessageInfo): boole
return false
}
return !(message.status === "complete" || message.status === "sent")
return !(record.status === "complete" || record.status === "sent")
}
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
const session = getSession(instanceId, sessionId)
if (!session) {
return "idle"
}
const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}
const latestUserInfo = getLastMessageInfo(session, "user")
const latestAssistantInfo = getLastMessageInfo(session, "assistant")
const lastMessage = getLastMessage(session)
if (!lastMessage) {
const latestInfo = getLastMessageInfo(session)
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user")
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant")
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
if (!lastRecord) {
const latestInfo = latestUserInfo ?? latestAssistantInfo
if (!latestInfo) {
return "idle"
}
@@ -138,12 +141,11 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return infoCompleted ? "idle" : "working"
}
if (lastMessage.type === "user") {
if (lastRecord.role === "user") {
return "working"
}
const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo
if (isAssistantStillGenerating(lastMessage, infoForMessage)) {
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
return "working"
}

View File

@@ -28,7 +28,6 @@ import {
setSessionDraftPrompt,
} from "./session-state"
import { getDefaultModel } from "./session-models"
import { computeDisplayParts, removeSessionIndexes } from "./session-messages"
import {
createSession,
deleteSession,
@@ -79,7 +78,6 @@ export {
clearActiveParentSession,
clearInstanceDraftPrompts,
clearSessionDraftPrompt,
computeDisplayParts,
createSession,
deleteSession,
executeCustomCommand,
@@ -102,7 +100,6 @@ export {
loadMessages,
loading,
providers,
removeSessionIndexes,
sendMessage,
sessionInfoByInstance,
sessions,

View File

@@ -6,8 +6,42 @@
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
margin-top: 0;
margin-bottom: 0;
}
.message-item-base:not(.assistant-message) {
margin-top: 0;
margin-bottom: 0;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
margin-top: 0;
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
margin: 0;
}
.message-step-finish-flush {
margin-top: -0.125rem;
}
.message-step-usage {
@apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)];
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);
}
.message-queued-badge {
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
background-color: var(--accent-primary);
@@ -101,3 +135,171 @@
.reasoning-label {
font-weight: var(--font-weight-medium);
}
.message-step-card {
@apply flex flex-col gap-2 px-3 py-2;
color: inherit;
border-radius: 0;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);
}
.message-step-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
@apply flex items-center justify-between w-full;
}
.message-step-title-left {
@apply flex items-center gap-2 text-[var(--text-muted)];
}
.message-step-title-left span:last-child {
@apply font-medium text-[11px];
}
.message-step-time {
@apply text-[11px] text-[var(--text-muted)] font-normal ml-auto;
}
.message-step-meta-inline {
@apply inline-flex flex-wrap items-center gap-2 text-[11px] font-medium;
color: var(--message-assistant-border);
}
.message-step-reason {
@apply text-[11px] font-medium;
color: var(--text-muted);
}
.message-step-finish-spacer {
@apply mt-4;
}
.message-reasoning-card {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);
margin-top: 0;
margin-bottom: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.message-reasoning-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
background: none;
border: none;
padding: 0.25rem 0.6rem;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.message-reasoning-toggle:hover {
background-color: var(--surface-hover);
}
.message-reasoning-toggle:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-primary);
}
.message-reasoning-label {
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--message-assistant-border);
}
.message-reasoning-meta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.message-reasoning-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-base);
border-radius: 0.375rem;
background-color: transparent;
color: var(--text-muted);
font-weight: var(--font-weight-semibold);
font-size: 0.75rem;
line-height: 1;
letter-spacing: 0.01em;
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.message-reasoning-toggle:hover .message-reasoning-indicator {
background-color: var(--surface-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.message-reasoning-indicator:active {
transform: scale(0.97);
}
.message-reasoning-time {
font-size: 11px;
color: var(--text-muted);
}
.message-reasoning-expanded {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.message-reasoning-body {
padding: 0;
background-color: var(--surface-code);
margin: 0.75rem;
}
.message-reasoning-output {
@apply flex flex-col;
margin: 0;
padding: 0.75rem;
max-height: 30rem;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-base) transparent;
scrollbar-gutter: stable both-edges;
background-color: var(--surface-code);
}
.message-reasoning-text {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
color: var(--text-primary);
white-space: pre-wrap;
margin: 0;
}

View File

@@ -65,11 +65,17 @@
}
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
}
.message-stream-block {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.message-scroll-button-wrapper {
position: absolute;
right: 1rem;

View File

@@ -36,20 +36,10 @@ export type ClientPart = SDKPart & {
sessionID?: string
messageID?: string
synthetic?: boolean
version?: number
renderCache?: RenderCache
pendingPermission?: PendingPermissionState
}
export interface MessageDisplayParts {
text: ClientPart[]
tool: ClientPart[]
reasoning: ClientPart[]
combined: ClientPart[]
showThinking: boolean
version: number
}
export interface Message {
id: string
sessionId: string
@@ -58,7 +48,6 @@ export interface Message {
timestamp: number
status: "sending" | "sent" | "streaming" | "complete" | "error"
version: number
displayParts?: MessageDisplayParts
}
export interface TextPart {

View File

@@ -1,9 +1,8 @@
import type { Message, MessageInfo } from "./message"
import type {
import type {
Session as SDKSession,
Agent as SDKAgent,
Agent as SDKAgent,
Provider as SDKProvider,
Model as SDKModel
Model as SDKModel,
} from "@opencode-ai/sdk"
// Export SDK types for external use
@@ -17,18 +16,17 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting"
// Our client-specific Session interface extending SDK Session
export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'projectID' | 'directory' | 'parentID'> {
instanceId: string // Client-specific field
parentId: string | null // Client-specific field (override parentID)
agent: string // Client-specific field
model: { // Client-specific field
export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
instanceId: string // Client-specific field
parentId: string | null // Client-specific field (override parentID)
agent: string // Client-specific field
model: {
providerId: string
modelId: string
}
messages: Message[] // Client-specific field
messagesInfo: Map<string, MessageInfo> // Client-specific field
version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
}
// Adapter function to convert SDK Session to client Session
@@ -36,7 +34,7 @@ export function createClientSession(
sdkSession: import("@opencode-ai/sdk").Session,
instanceId: string,
agent: string = "",
model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }
model: { providerId: string; modelId: string } = { providerId: "", modelId: "" },
): Session {
return {
...sdkSession,
@@ -44,8 +42,6 @@ export function createClientSession(
parentId: sdkSession.parentID || null,
agent,
model,
messages: [],
messagesInfo: new Map(),
}
}

View File

@@ -4,6 +4,8 @@
Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states.
> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`.
## Prerequisites
- Task 006 completed (Tab navigation in place)

View File

@@ -1,5 +1,7 @@
# Task 008: SSE Integration - Real-time Message Streaming
> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store.
## Status: TODO
## Objective

View File

@@ -32,3 +32,4 @@ Finish migrating the message stream container, tool call blocks, and reasoning U
## Notes
- Branch suggestion: `feature/task-048-message-stream-refactor`.
- Capture short screen recording or screenshots if tool call layout adjustments were required.
- Legacy `message-stream.tsx` has since been replaced by `message-stream-v2.tsx` using the normalized message store.