Compare commits

..

62 Commits

Author SHA1 Message Date
Shantur Rathore
ff356ac5ea bump Version to 0.2.6 2025-11-27 19:42:55 +00:00
Shantur Rathore
d68b92ff38 Gate npm publish on successful builds 2025-11-27 19:41:02 +00:00
Shantur Rathore
940216d98b Ensure tauri prebuild installs UI workspace deps 2025-11-27 19:40:36 +00:00
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
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
Shantur Rathore
1741e49568 aggregate instance SSE streams through server bus so UI uses single connection 2025-11-23 19:07:10 +00:00
Shantur Rathore
8577b3d1e6 show loading status only for errors 2025-11-23 14:42:09 +00:00
Shantur Rathore
011533b3c4 improve prompt submission history handling 2025-11-23 14:41:49 +00:00
Shantur Rathore
002efad9ad cap CLI proxy concurrency 2025-11-23 14:40:37 +00:00
Shantur Rathore
3ce5569b82 route CLI logs to host processes only 2025-11-23 13:38:50 +00:00
Shantur Rathore
d7c0c225b9 chore: align monorepo package versions with 0.2.2 2025-11-23 12:05:36 +00:00
Shantur Rathore
f4de0103a8 Resolve CLI binary metadata for UI 2025-11-23 11:59:12 +00:00
Shantur Rathore
0a9b7fafed Align Tauri dev flow with shared renderer 2025-11-23 10:37:45 +00:00
Shantur Rathore
073604c9f5 Force dark theme defaults across shells 2025-11-23 10:00:16 +00:00
Shantur Rathore
4062b43380 Enable native dialogs across shells 2025-11-23 00:36:43 +00:00
Shantur Rathore
00bd9f9c1c Allow proxy streams to stay open 2025-11-22 21:50:04 +00:00
Shantur Rathore
3edb0ac09e Add runtime environment detection 2025-11-22 21:46:53 +00:00
Shantur Rathore
e9f3c4ee52 Unify loader assets across shells 2025-11-22 21:20:29 +00:00
Shantur Rathore
92420d9e02 Move screenshots to correct folder 2025-11-21 21:59:58 +00:00
109 changed files with 6260 additions and 2553 deletions

View File

@@ -337,9 +337,6 @@ jobs:
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Bundle server assets for Tauri
run: npm run bundle:server --workspace @codenomad/tauri-app
- name: Build Linux bundle (Tauri) - name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app run: npm run build --workspace @codenomad/tauri-app

View File

@@ -74,7 +74,9 @@ jobs:
secrets: inherit secrets: inherit
publish-server: publish-server:
needs: prepare-release needs:
- prepare-release
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}

View File

@@ -1,6 +1,5 @@
--- ---
description: Develops Web UI components. description: Develops Web UI components.
mode: all 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. 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

@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
![Command palette overlay](docs/screenshots/command-palette.png) ![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._ _Global command palette for keyboard-first control._
![Image Previews](images/image-previews.png) ![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._ _Rich media previews for images and assets._
![Browser Support](images/browser-support.png) ![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._ _Browser support via CodeNomad Server._
</details> </details>

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 845 KiB

After

Width:  |  Height:  |  Size: 845 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

12
package-lock.json generated
View File

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

View File

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

View File

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src") const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer") const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html") const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({ export default defineConfig({
main: { main: {
@@ -54,7 +55,10 @@ export default defineConfig({
build: { build: {
outDir: resolve(__dirname, "dist/renderer"), outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: { rollupOptions: {
input: uiRendererEntry, input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
}, },
}, },
}, },

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, ipcMain } from "electron" import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => { cliManager.on("status", (status: CliStatus) => {
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
} }
}) })
cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:log", entry)
}
})
cliManager.on("error", (error: Error) => { cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) { if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message }) mainWindow.webContents.send("cli:error", { message: error.message })
@@ -27,4 +33,27 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
}) })
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
} }

View File

@@ -30,22 +30,63 @@ function getIconPath() {
return join(mainDirname, "../resources/icon.png") return join(mainDirname, "../resources/icon.png")
} }
function getLoadingHtmlPath() { type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) { if (app.isPackaged) {
return join(process.resourcesPath, "loading.html") return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
} }
const distResources = join(mainDirname, "../resources/loading.html") try {
if (existsSync(distResources)) { const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return distResources return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
} }
const devResources = join(mainDirname, "../electron/resources/loading.html") return join(app.getAppPath(), "dist/renderer/loading.html")
if (existsSync(devResources)) { }
return devResources
}
return join(process.cwd(), "electron/resources/loading.html") function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
} }
let cachedPreloadPath: string | null = null let cachedPreloadPath: string | null = null
@@ -116,10 +157,9 @@ function createWindow() {
mainWindow.webContents.session.setSpellCheckerEnabled(false) mainWindow.webContents.session.setSpellCheckerEnabled(false)
} }
const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error)) loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" }) mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -156,8 +196,7 @@ function showLoadingScreen(force = false) {
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null currentCliUrl = null
pendingCliUrl = null pendingCliUrl = null
const loadingHtml = getLoadingHtmlPath() loadLoadingScreen(mainWindow)
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
} }
function startCliPreload(url: string) { function startCliPreload(url: string) {

View File

@@ -263,47 +263,32 @@ export class CliProcessManager extends EventEmitter {
private resolveCliEntry(options: StartOptions): CliEntryResolution { private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) { if (options.dev) {
const tsxPath = this.resolveTsx() const tsxPath = this.resolveTsx()
const sourceCandidates = [ if (!tsxPath) {
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"), throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
]
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
if (tsxPath && sourceEntry) {
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
} }
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
} }
const dist = this.tryResolveDist() const distEntry = this.resolveProdEntry()
if (dist) { return { entry: distEntry, runner: "node" }
return { entry: dist, runner: "node" }
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
} }
private resolveTsx(): string | null { private resolveTsx(): string | null {
try {
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
return null
}
return null
}
private tryResolveDist(): string | null {
const candidates: Array<string | (() => string)> = [ const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"), () => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }), () => nodeRequire.resolve("tsx/dist/cli.mjs"),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), () => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"), path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"), path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
] ]
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const resolved = typeof candidate === "function" ? candidate() : candidate const resolved = typeof candidate === "function" ? candidate() : candidate
@@ -314,7 +299,28 @@ export class CliProcessManager extends EventEmitter {
continue continue
} }
} }
return null return null
} }
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
} }

View File

@@ -59,7 +59,7 @@ export function setupStorageIPC() {
return await readConfigWithCache() return await readConfigWithCache()
} catch (error) { } catch (error) {
// Return empty config if file doesn't exist // 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

@@ -5,15 +5,12 @@ const electronAPI = {
ipcRenderer.on("cli:status", (_, data) => callback(data)) ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status") return () => ipcRenderer.removeAllListeners("cli:status")
}, },
onCliLog: (callback) => {
ipcRenderer.on("cli:log", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:log")
},
onCliError: (callback) => { onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data)) ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error") return () => ipcRenderer.removeAllListeners("cli:error")
}, },
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
} }
contextBridge.exposeInMainWorld("electronAPI", electronAPI) contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 22px;
border-radius: 18px;
background: #0f1421;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 1.1rem;
color: #aeb3c4;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 20px;
border-radius: 14px;
background: rgba(13, 16, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cad0dd;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…"
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
pickPhrase()
button?.addEventListener("click", pickPhrase)
</script>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -111,6 +111,14 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection agentModelSelections: AgentModelSelection
} }
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord { export interface BinaryRecord {
id: string id: string
path: string path: string
@@ -157,6 +165,8 @@ export type WorkspaceEventType =
| "config.appChanged" | "config.appChanged"
| "config.binariesChanged" | "config.binariesChanged"
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
export type WorkspaceEventPayload = export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -167,6 +177,8 @@ export type WorkspaceEventPayload =
| { type: "config.appChanged"; config: AppConfig } | { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] } | { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
export interface ServerMeta { export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */ /** Base URL clients should target for REST calls (useful for Electron embedding). */

View File

@@ -10,12 +10,14 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
const PreferencesSchema = z.object({ const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false), showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]), modelRecents: z.array(ModelPreferenceSchema).default([]),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
}) })
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({

View File

@@ -8,7 +8,9 @@ export class EventBus extends EventEmitter {
} }
publish(event: WorkspaceEventPayload): boolean { publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event") if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ event }, "Publishing workspace event")
}
return super.emit(event.type, event) return super.emit(event.type, event)
} }
@@ -22,6 +24,8 @@ export class EventBus extends EventEmitter {
this.on("config.appChanged", handler) this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler) this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler) this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
return () => { return () => {
this.off("workspace.created", handler) this.off("workspace.created", handler)
this.off("workspace.started", handler) this.off("workspace.started", handler)
@@ -31,6 +35,8 @@ export class EventBus extends EventEmitter {
this.off("config.appChanged", handler) this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler) this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
} }
} }
} }

View File

@@ -14,10 +14,12 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types" import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store" import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string } const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
@@ -78,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return { return {
port: parsed.port, port: parsed.port,
host: parsed.host, host: normalizedHost,
rootDir: resolvedRoot, rootDir: resolvedRoot,
configPath: parsed.config, configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot), unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -100,6 +104,13 @@ function parsePort(input: string): number {
return value return value
} }
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() { async function main() {
const options = parseCliOptions(process.argv.slice(2)) const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" }) const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -121,6 +132,11 @@ async function main() {
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`, httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -169,6 +185,7 @@ async function main() {
} }
try { try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown() await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete") logger.info("Workspace manager shutdown complete")
} catch (error) { } catch (error) {

View File

@@ -65,6 +65,12 @@ export function createHttpServer(deps: HttpServerDeps) {
app.register(replyFrom, { app.register(replyFrom, {
contentTypesToEncode: [], contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
}) })
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })

View File

@@ -0,0 +1,190 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -1,4 +1,5 @@
import path from "path" import path from "path"
import { spawnSync } from "child_process"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
@@ -65,10 +66,11 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault() const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath) clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace") this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance` const proxyPath = `/workspaces/${id}/instance`
@@ -79,14 +81,20 @@ export class WorkspaceManager {
name, name,
status: "starting", status: "starting",
proxyPath, proxyPath,
binaryId: binary.id, binaryId: resolvedBinaryPath,
binaryLabel: binary.label, binaryLabel: binary.label,
binaryVersion: binary.version, binaryVersion: binary.version,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {} const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
@@ -95,7 +103,7 @@ export class WorkspaceManager {
const { pid, port } = await this.runtime.launch({ const { pid, port } = await this.runtime.launch({
workspaceId: id, workspaceId: id,
folder: workspacePath, folder: workspacePath,
binaryPath: binary.path, binaryPath: resolvedBinaryPath,
environment, environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
@@ -161,6 +169,70 @@ export class WorkspaceManager {
return workspace return workspace
} }
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId) const workspace = this.workspaces.get(workspaceId)
if (!workspace) return if (!workspace) return

View File

@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) } const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process") this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, { const child = spawn(options.binaryPath, args, {
cwd: options.folder, cwd: options.folder,
env, env,

View File

@@ -47,6 +47,61 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ashpd"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -325,6 +380,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"thiserror 1.0.69", "thiserror 1.0.69",
"which", "which",
] ]
@@ -339,6 +395,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@@ -577,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2 0.6.2",
"libc",
"objc2 0.6.3", "objc2 0.6.3",
] ]
@@ -591,6 +658,15 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "dlib"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
]
[[package]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.8.0" version = "0.8.0"
@@ -614,6 +690,12 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -676,6 +758,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -703,6 +812,33 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -822,6 +958,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.31"
@@ -1656,6 +1805,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -1813,6 +1968,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
@@ -2132,6 +2300,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2157,6 +2335,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2346,7 +2530,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.12.1", "indexmap 2.12.1",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -2462,6 +2646,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -2511,6 +2704,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@@ -2531,6 +2734,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@@ -2549,6 +2762,15 @@ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
] ]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@@ -2677,6 +2899,31 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2695,10 +2942,23 @@ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -2771,6 +3031,12 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -2992,6 +3258,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.7" version = "0.3.7"
@@ -3086,6 +3361,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@@ -3354,6 +3635,63 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"toml 0.9.8",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"toml 0.9.8",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.9.1" version = "2.9.1"
@@ -3455,6 +3793,19 @@ dependencies = [
"toml 0.9.8", "toml 0.9.8",
] ]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.4.3" version = "0.4.3"
@@ -3557,7 +3908,9 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -3722,9 +4075,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.34" version = "0.1.34"
@@ -3774,6 +4139,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"winapi",
]
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@@ -4018,6 +4394,66 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "wayland-backend"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.2",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.10.0",
"rustix 1.1.2",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.82" version = "0.3.82"
@@ -4117,7 +4553,7 @@ dependencies = [
"either", "either",
"home", "home",
"once_cell", "once_cell",
"rustix", "rustix 0.38.44",
] ]
[[package]] [[package]]
@@ -4674,6 +5110,62 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zbus"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"nix",
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.13",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.110",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.28" version = "0.8.28"
@@ -4747,3 +5239,44 @@ dependencies = [
"quote", "quote",
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "zvariant"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.13",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.110",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.110",
"winnow 0.7.13",
]

View File

@@ -1,12 +1,15 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.2.1", "version": "0.2.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild", "bundle:server": "npm run prebuild",
"build": "tauri build" "build": "npx --yes @tauri-apps/cli@^2.9.4 build"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -1,45 +1,158 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require("fs"); const fs = require("fs")
const path = require("path"); const path = require("path")
const { execSync } = require("child_process"); const { execSync } = require("child_process")
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", ".."); const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server"); const serverRoot = path.resolve(root, "..", "server")
const dest = path.resolve(root, "src-tauri", "resources", "server"); const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]; const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm ci --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
function ensureServerBuild() { function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist"); const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public"); const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) { if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return; return
} }
console.log("[prebuild] server build missing; running workspace build..."); console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", { execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot, cwd: workspaceRoot,
stdio: "inherit", stdio: "inherit",
}); env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) { if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build"); throw new Error("[prebuild] server artifacts still missing after build")
} }
} }
ensureServerBuild(); function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
fs.rmSync(dest, { recursive: true, force: true }); if (fs.existsSync(loadingHtml)) {
fs.mkdirSync(dest, { recursive: true }); return
}
for (const name of sources) {
const from = path.join(serverRoot, name); console.log("[prebuild] ui build missing; running workspace build...")
const to = path.join(dest, name); execSync("npm --workspace @codenomad/ui run build", {
if (!fs.existsSync(from)) { cwd: workspaceRoot,
console.warn(`[prebuild] skipped missing ${from}`); stdio: "inherit",
continue; })
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
} }
fs.cpSync(from, to, { recursive: true });
console.log(`[prebuild] copied ${from} -> ${to}`);
} }
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function ensureUiDevDependencies() {
if (fs.existsSync(viteBinPath)) {
return
}
console.log("[prebuild] ensuring ui build dependencies...")
execSync(uiDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureServerBuild()
ensureUiBuild()
ensureServerDependencies()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -17,3 +17,4 @@ thiserror = "1"
anyhow = "1" anyhow = "1"
which = "4" which = "4"
libc = "0.2" libc = "0.2"
tauri-plugin-dialog = "2"

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{} {"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}

View File

@@ -2143,6 +2143,72 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
} }
] ]
}, },

View File

@@ -2143,6 +2143,72 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
} }
] ]
}, },

View File

@@ -355,7 +355,7 @@ impl CliProcessManager {
Ok(_) => { Ok(_) => {
let line = buffer.trim_end(); let line = buffer.trim_end();
if !line.is_empty() { if !line.is_empty() {
let _ = app.emit("cli:log", json!({"stream": stream, "message": line})); log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) { if ready.load(Ordering::SeqCst) {
continue; continue;

View File

@@ -19,6 +19,7 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
}) })

View File

@@ -4,17 +4,20 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "ai.opencode.client", "identifier": "ai.opencode.client",
"build": { "build": {
"beforeDevCommand": "", "beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server", "beforeBuildCommand": "npm run bundle:server",
"frontendDist": "../src" "frontendDist": "resources/ui-loading"
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "CodeNomad", "title": "CodeNomad",
"url": "index.html", "url": "loading.html",
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"minWidth": 800, "minWidth": 800,
@@ -22,21 +25,23 @@
"center": true, "center": true,
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"decorations": true "decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a"
} }
], ],
"security": { "security": {
"assetProtocol": { "assetProtocol": {
"scope": ["**"] "scope": ["**"]
} },
"capabilities": ["main-window-native-dialogs"]
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"resources": [ "resources": [
"../src/index.html", "resources/server",
"../src/icon.png", "resources/ui-loading"
"resources/server"
], ],
"icon": ["icon.icns", "icon.ico", "icon.png"], "icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"] "targets": ["app", "appimage", "deb", "rpm", "nsis"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,197 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff9ea9;
font-size: 0.95rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
<div class="error" id="error"></div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…",
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
const errorEl = document.getElementById("error")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
function setError(message) {
errorEl.textContent = message || ""
}
function navigateTo(url) {
if (!url) return
window.location.replace(url)
}
async function bootstrap() {
pickPhrase()
button?.addEventListener("click", pickPhrase)
if (!window.__TAURI__ || !window.__TAURI__.event || !window.__TAURI__.invoke) {
return
}
const { listen } = window.__TAURI__.event
const invoke = window.__TAURI__.invoke
listen("cli:ready", (event) => {
const payload = event?.payload || {}
if (payload.url) {
navigateTo(payload.url)
}
})
listen("cli:error", (event) => {
const payload = event?.payload || {}
if (payload.message) {
setError(payload.message)
}
})
listen("cli:status", (event) => {
const payload = event?.payload || {}
if (payload.state !== "ready") {
setError("")
}
})
try {
const status = await invoke("cli_get_status")
if (status?.state === "ready" && status.url) {
navigateTo(status.url)
}
if (status?.state === "error" && status.error) {
setError(status.error)
}
} catch (error) {
setError(String(error))
}
}
bootstrap()
</script>
</body>
</html>

View File

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

View File

@@ -47,9 +47,11 @@ const App: Component = () => {
preferences, preferences,
recordWorkspaceLaunch, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null) const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
@@ -205,9 +207,11 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({ const { commands: paletteCommands, executeCommand } = useCommands({
preferences, preferences,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,

View File

@@ -3,7 +3,6 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions" import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import Kbd from "./kbd"
interface AgentSelectorProps { interface AgentSelectorProps {
instanceId: string instanceId: string
@@ -116,9 +115,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content> </Select.Content>
</Select.Portal> </Select.Portal>
</Select> </Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div> </div>
) )
} }

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

View File

@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -21,6 +22,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders() const folders = () => recentFolders()
@@ -29,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change // Update selected binary when preferences change
createEffect(() => { createEffect(() => {
const lastUsed = preferences().lastUsedBinary const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) { if (!lastUsed) return
setSelectedBinary(lastUsed) setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
}
}) })
@@ -78,7 +79,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isBrowseShortcut) { if (isBrowseShortcut) {
e.preventDefault() e.preventDefault()
handleBrowse() void handleBrowse()
return return
} }
@@ -172,9 +173,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
defaultPath: fallbackPath,
})
if (selected) {
handleFolderSelect(selected)
}
return
}
setIsFolderBrowserOpen(true) setIsFolderBrowserOpen(true)
} }
@@ -219,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" /> <img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div> </div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p> <p class="text-base text-secondary">Select a folder to start coding with AI</p>
@@ -306,14 +318,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show> </Show>
<div class="panel shrink-0"> <div class="panel shrink-0">
<div class="panel-header"> <div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2> <h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p> <p class="panel-subtitle">Select any folder on your computer</p>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<button <button
onClick={handleBrowse} onClick={() => void handleBrowse()}
disabled={props.isLoading} disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed" class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")} onMouseEnter={() => setFocusMode("new")}
@@ -342,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</div> </div>
<div class="mt-1 panel panel-footer shrink-0"> <div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<Show when={folders().length > 0}> <Show when={folders().length > 0}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">

View File

@@ -48,6 +48,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadata = () => props.instance.metadata const metadata = () => props.instance.metadata
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
const mcpServers = () => { const mcpServers = () => {
const status = metadata()?.mcpStatus const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : [] return status ? parseMcpStatus(status) : []
@@ -104,11 +105,12 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
...(lspStatus ? { lspStatus } : {}), ...(lspStatus ? { lspStatus } : {}),
} }
if (!nextMetadata.version) { if (!nextMetadata.version && instance.binaryVersion) {
nextMetadata.version = "0.15.8" nextMetadata.version = instance.binaryVersion
} }
updateInstance(instanceId, { metadata: nextMetadata }) updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
console.error("Failed to load instance metadata:", error) console.error("Failed to load instance metadata:", error)
@@ -173,13 +175,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
)} )}
</Show> </Show>
<Show when={metadata()?.version}> <Show when={binaryVersion()}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version OpenCode Version
</div> </div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{metadata()?.version} v{binaryVersion()}
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -281,7 +281,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer hidden sm:block">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>

View File

@@ -14,6 +14,7 @@ import InfoView from "../info-view"
import AgentSelector from "../agent-selector" import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector" import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette" import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel" import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
@@ -28,7 +29,7 @@ interface InstanceShellProps {
onExecuteCommand: (command: Command) => void onExecuteCommand: (command: Command) => void
} }
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const InstanceShell: Component<InstanceShellProps> = (props) => { const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)} onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/> />
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector <ModelSelector
instanceId={props.instance.id} instanceId={props.instance.id}
sessionId={activeSession().id} sessionId={activeSession().id}
currentModel={activeSession().model} currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)} onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/> />
</div> </div>
</> </>
)} )}

View File

@@ -1,23 +1,27 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message" import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
interface MessageItemProps { interface MessageItemProps {
message: Message record: MessageRecord
messageInfo?: MessageInfo messageInfo?: MessageInfo
instanceId: string instanceId: string
sessionId: string sessionId: string
isQueued?: boolean isQueued?: boolean
parts?: ClientPart[] combinedParts: ClientPart[]
orderedParts: ClientPart[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user" const isUser = () => props.record.role === "user"
const timestamp = () => { 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" }) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
} }
@@ -27,10 +31,10 @@ export default function MessageItem(props: MessageItemProps) {
filename?: string filename?: string
} }
const displayParts = () => props.parts ?? props.message.parts const combinedParts = () => props.combinedParts
const fileAttachments = () => 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) => { const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) { if (part.filename && part.filename.trim().length > 0) {
@@ -120,7 +124,7 @@ export default function MessageItem(props: MessageItemProps) {
return true return true
} }
return displayParts().some((part) => partHasRenderableText(part)) return combinedParts().some((part) => partHasRenderableText(part))
} }
const isGenerating = () => { const isGenerating = () => {
@@ -130,15 +134,19 @@ export default function MessageItem(props: MessageItemProps) {
const handleRevert = () => { const handleRevert = () => {
if (props.onRevert && isUser()) { if (props.onRevert && isUser()) {
props.onRevert(props.message.id) props.onRevert(props.record.id)
} }
} }
if (!isUser() && !hasContent()) {
return null
}
const containerClass = () => const containerClass = () =>
isUser() isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]" ? "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)]" : "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const agentIdentifier = () => { const agentIdentifier = () => {
if (isUser()) return "" if (isUser()) return ""
const info = props.messageInfo const info = props.messageInfo
@@ -155,24 +163,30 @@ export default function MessageItem(props: MessageItemProps) {
if (modelID && providerID) return `${providerID}/${modelID}` if (modelID && providerID) return `${providerID}/${modelID}`
return modelID return modelID
} }
return ( return (
<div class={containerClass()}> <div class={containerClass()}>
<div class="flex justify-between items-center gap-2.5 pb-0.5"> <div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="flex flex-col"> <div class="flex flex-col">
<Show when={isUser()}> <Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span> <span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show> </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}> <Show when={isUser() && props.onRevert}>
<button <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" 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"
@@ -186,7 +200,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={isUser() && props.onFork}> <Show when={isUser() && props.onFork}>
<button <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" 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" title="Fork from this message"
aria-label="Fork from this message" aria-label="Fork from this message"
> >
@@ -213,72 +227,71 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<For each={displayParts()}> <For each={combinedParts()}>
{(part) => ( {(part) => (
<MessagePart <MessagePart
part={part} part={part}
messageType={props.message.type} messageType={props.record.role}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
/> />
)} )}
</For> </For>
<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.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div> </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={props.message.status === "sending"}>
<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>
</div> </div>
) )
} }

View File

@@ -33,6 +33,38 @@ export default function MessagePart(props: MessagePartProps) {
return "" 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 createTextPartForMarkdown = (): TextPart => {
const part = props.part const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") { 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> </Switch>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,707 +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 { setActiveInstanceId } from "../stores/instances"
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 TUI (e.g., "110K", "1.2M")
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// Format session info for the session view header
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
if (contextWindow > 0) {
const windowStr = formatTokens(contextWindow)
const usageStr = formatTokens(usageTokens)
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
return `${usageStr} of ${windowStr} (${percent}%)`
}
return formatTokens(usageTokens)
}
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)
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) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
},
)
const formattedSessionInfo = createMemo(() => {
const info = sessionInfo()
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
})
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 items-center gap-2 text-sm font-medium">
<span>{formattedSessionInfo()}</span>
</div>
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" />
</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

@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions" import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session" import type { Model } from "../types/session"
import Kbd from "./kbd"
interface ModelSelectorProps { interface ModelSelectorProps {
instanceId: string instanceId: string
@@ -132,9 +131,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content> </Combobox.Content>
</Combobox.Portal> </Combobox.Portal>
</Combobox> </Combobox>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+m" />
</span>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog" import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
interface BinaryOption { interface BinaryOption {
path: string path: string
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>()) const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>()) const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary const lastUsedBinary = () => preferences().lastUsedBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -128,9 +131,19 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
} }
function handleBrowseBinary() { async function handleBrowseBinary() {
if (props.disabled) return if (props.disabled) return
setValidationError(null) setValidationError(null)
if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({
title: "Select OpenCode Binary",
})
if (selected) {
setCustomPath(selected)
void handleValidateAndAdd(selected)
}
return
}
setIsBinaryBrowserOpen(true) setIsBinaryBrowserOpen(true)
} }
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<button <button
type="button" type="button"
onClick={handleBrowseBinary} onClick={() => void handleBrowseBinary()}
disabled={props.disabled} disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2" class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
> >

View File

@@ -7,7 +7,6 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
import type { Attachment } from "../types/attachment" import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import Kbd from "./kbd" import Kbd from "./kbd"
import HintRow from "./hint-row"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
@@ -25,6 +24,7 @@ interface PromptInputProps {
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const [prompt, setPromptInternal] = createSignal("") const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([]) const [history, setHistory] = createSignal<string[]>([])
const HISTORY_LIMIT = 100
const [historyIndex, setHistoryIndex] = createSignal(-1) const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null) const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false) const [, setIsFocused] = createSignal(false)
@@ -499,11 +499,27 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() { async function handleSend() {
const text = prompt().trim() const text = prompt().trim()
const currentAttachments = attachments() const currentAttachments = attachments()
if (props.disabled || !text) return if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments) const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell" const isShellMode = mode() === "shell"
const refreshHistory = async () => {
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
setHistory((prev) => {
const next = [resolvedPrompt, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
return next
})
setHistoryIndex(-1)
} catch (historyError) {
console.error("Failed to update prompt history:", historyError)
}
}
clearPrompt() clearPrompt()
clearAttachments(props.instanceId, props.sessionId) clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
@@ -512,10 +528,6 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null) setHistoryDraft(null)
try { try {
await addToHistory(props.instanceFolder, resolvedPrompt)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
if (isShellMode) { if (isShellMode) {
if (props.onRunShell) { if (props.onRunShell) {
await props.onRunShell(resolvedPrompt) await props.onRunShell(resolvedPrompt)
@@ -523,8 +535,9 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else { } else {
await props.onSend(text, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
void refreshHistory()
} catch (error) { } catch (error) {
console.error("Failed to send message:", error) console.error("Failed to send message:", error)
showAlertDialog("Failed to send message", { showAlertDialog("Failed to send message", {
@@ -763,6 +776,7 @@ export default function PromptInput(props: PromptInputProps) {
} }
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" }) const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
@@ -870,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
</For> </For>
</div> </div>
</Show> </Show>
<textarea <div class="prompt-input-field">
ref={textareaRef} <textarea
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`} ref={textareaRef}
placeholder={ class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
mode() === "shell" placeholder={
? "Run a shell command (Esc to exit)..." mode() === "shell"
: "Type your message, @file, @agent, or paste images and text..." ? "Run a shell command (Esc to exit)..."
} : "Type your message, @file, @agent, or paste images and text..."
value={prompt()} }
onInput={handleInput} value={prompt()}
onKeyDown={handleKeyDown} onInput={handleInput}
onPaste={handlePaste} onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)} onPaste={handlePaste}
onBlur={() => setIsFocused(false)} onFocus={() => setIsFocused(true)}
disabled={props.disabled} onBlur={() => setIsFocused(false)}
rows={4} disabled={props.disabled}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}} rows={4}
spellcheck={false} style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
autocorrect="off" spellcheck={false}
autoCapitalize="off" autocorrect="off"
autocomplete="off" autoCapitalize="off"
/> autocomplete="off"
/>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</div>
</div> </div>
<button <button
@@ -911,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</button> </button>
</div> </div>
<div class="prompt-input-hints">
<div class="flex justify-between w-full gap-4">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
<span class="ml-2">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
</>
}
>
<span class="font-medium" style="color: var(--status-warning);">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
<Show when={mode() === "shell"}>
<HintRow>Shell mode active</HintRow>
</Show>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -24,9 +24,9 @@ interface SessionListProps {
} }
const MIN_WIDTH = 200 const MIN_WIDTH = 200
const MAX_WIDTH = 500 const MAX_WIDTH = 520
const DEFAULT_WIDTH = 280 const DEFAULT_WIDTH = 360
const STORAGE_KEY = "opencode-session-sidebar-width" const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string { function formatSessionStatus(status: SessionStatus): string {
switch (status) { switch (status) {

View File

@@ -7,54 +7,72 @@ interface ContextUsagePanelProps {
sessionId: string sessionId: string
} }
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => { const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const info = createMemo( const info = createMemo(
() => () =>
getSessionInfo(props.instanceId, props.sessionId) ?? { getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0, cost: 0,
contextWindow: 0, contextWindow: 0,
isSubscriptionModel: false, isSubscriptionModel: false,
contextUsageTokens: 0, inputTokens: 0,
contextUsagePercent: null, outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
}, },
) )
const tokens = createMemo(() => info().tokens) const inputTokens = createMemo(() => info().inputTokens ?? 0)
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0) const outputTokens = createMemo(() => info().outputTokens ?? 0)
const contextWindow = createMemo(() => info().contextWindow) const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const contextUsagePercent = createMemo(() => info().contextUsagePercent) const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costLabel = createMemo(() => { const costValue = createMemo(() => {
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan" const value = info().isSubscriptionModel ? 0 : info().cost
return `$${info().cost.toFixed(2)} spent` return value > 0 ? value : 0
}) })
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return "--"
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return ( return (
<div class="session-context-panel border-r border-base border-b px-3 py-3"> <div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div> <div class={headingClass}>Tokens</div>
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div> <div class={chipClass}>
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div> <span class={chipLabelClass}>Input</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
</div> </div>
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div> <div class={chipClass}>
</div> <span class={chipLabelClass}>Output</span>
<div class="mt-4"> <span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
</div> </div>
<div class="text-sm text-primary/90"> <div class={chipClass}>
{contextWindow() <span class={chipLabelClass}>Cost</span>
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}` <span class="font-semibold text-primary">{costDisplay()}</span>
: "Window size unavailable"}
</div> </div>
</div> </div>
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
<div <div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]" <div class={headingClass}>Context</div>
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }} <div class={chipClass}>
/> <span class={chipLabelClass}>Used</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Avail</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div>
</div> </div>
</div> </div>
) )

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

View File

@@ -1,15 +1,17 @@
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state" import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { messageStoreBus } from "../stores/message-v2/bus"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer" import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getLanguageFromPath } from "../lib/markdown" import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils" 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 { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences" import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances" 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" }> 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( function makeRenderCacheKey(
toolCallId?: string | null, toolCallId?: string | null,
messageId?: string, messageId?: string,
messageVersion?: number, partId?: string | null,
partVersion?: number, variant = "default",
) { ) {
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}` const messageComponent = messageId ?? "unknown-message"
const keyBase = `${messageId}:${toolCallId}` const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
return `${keyBase}::${suffix}` return `${messageComponent}:${toolCallComponent}:${variant}`
}
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)
})
} }
@@ -346,7 +319,36 @@ export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig() const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme() const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || "" 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 expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
@@ -374,30 +376,49 @@ export default function ToolCall(props: ToolCallProps) {
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let toolCallRootRef: HTMLDivElement | undefined let toolCallRootRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
const id = toolCallId()
if (!id || !scrollContainerRef) return const scrollScopeId = createMemo(() => {
restoreScrollState(id, scrollContainerRef) 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 initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const resolvedElement = element || undefined const resolvedElement = element || undefined
scrollContainerRef = resolvedElement scrollContainerRef = resolvedElement
const id = toolCallId() if (!resolvedElement) return
if (!resolvedElement || !id) return restoreScrollSnapshot(resolvedElement)
if (!toolScrollState.has(id)) {
requestAnimationFrame(() => {
if (!scrollContainerRef || toolCallId() !== id) return
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
updateScrollState(id, scrollContainerRef)
})
} else {
restoreScrollState(id, resolvedElement)
}
} }
createEffect(() => { 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(() => { createEffect(() => {
if (props.toolCall?.tool !== "task") return if (props.toolCall?.tool !== "task") return
const state = props.toolCall?.state const state = props.toolCall?.state
@@ -725,25 +736,20 @@ export default function ToolCall(props: ToolCallProps) {
return renderMarkdownTool(toolName, state) 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 relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff") const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion) const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light" const themeKey = isDark() ? "dark" : "light"
// Check if we have valid cache // Check if we have valid cache
let cachedHtml: string | undefined let cachedHtml: string | undefined
if (cacheKey) { const cached = cacheHandle.get<RenderCache>()
const cached = getToolRenderCache(cacheKey) const currentMode = diffMode()
const currentMode = diffMode() if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
if (cached && cachedHtml = cached.html
cached.text === payload.diffText &&
cached.theme === themeKey &&
cached.mode === currentMode) {
cachedHtml = cached.html
}
} }
const handleModeChange = (mode: DiffViewMode) => { const handleModeChange = (mode: DiffViewMode) => {
@@ -751,10 +757,6 @@ export default function ToolCall(props: ToolCallProps) {
} }
const handleDiffRendered = () => { 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) { if (!options?.disableScrollTracking) {
handleScrollRendered() handleScrollRendered()
} }
@@ -767,7 +769,7 @@ export default function ToolCall(props: ToolCallProps) {
if (options?.disableScrollTracking) return if (options?.disableScrollTracking) return
initializeScrollContainer(element) 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"> <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} theme={themeKey}
mode={diffMode()} mode={diffMode()}
cachedHtml={cachedHtml} cachedHtml={cachedHtml}
cacheKey={cacheKey} cacheEntryParams={cacheHandle.params()}
onRendered={handleDiffRendered} onRendered={handleDiffRendered}
/> />
</div> </div>
@@ -813,20 +815,15 @@ export default function ToolCall(props: ToolCallProps) {
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch" const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}` const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
const disableHighlight = state?.status === "running" const disableHighlight = state?.status === "running"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const markdownPart: TextPart = { type: "text", text: content } const markdownPart: TextPart = { type: "text", text: content }
if (cacheKey) { const cached = markdownCache.get<RenderCache>()
const cached = getToolRenderCache(cacheKey) if (cached) {
if (cached) { markdownPart.renderCache = cached
markdownPart.renderCache = cached
}
} }
const handleMarkdownRendered = () => { const handleMarkdownRendered = () => {
if (cacheKey) { markdownCache.set(markdownPart.renderCache)
setToolRenderCache(cacheKey, markdownPart.renderCache)
}
handleScrollRendered() handleScrollRendered()
} }
@@ -834,7 +831,7 @@ export default function ToolCall(props: ToolCallProps) {
<div <div
class={messageClass} class={messageClass}
ref={(element) => initializeScrollContainer(element)} ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
> >
<Markdown <Markdown
part={markdownPart} part={markdownPart}
@@ -1044,7 +1041,7 @@ export default function ToolCall(props: ToolCallProps) {
<div <div
class="message-text tool-call-markdown tool-call-task-container" class="message-text tool-call-markdown tool-call-task-container"
ref={(element) => initializeScrollContainer(element)} ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={summary}> <For each={summary}>
@@ -1122,7 +1119,7 @@ export default function ToolCall(props: ToolCallProps) {
{(payload) => ( {(payload) => (
<div class="tool-call-permission-diff"> <div class="tool-call-permission-diff">
{renderDiffTool(payload(), { {renderDiffTool(payload(), {
cacheKeySuffix: "::permission", variant: "permission-diff",
disableScrollTracking: true, disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff", label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})} })}

View File

@@ -1,4 +1,7 @@
export function formatTokenTotal(value: number): string { export function formatTokenTotal(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) { if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M` return `${(value / 1_000_000).toFixed(1)}M`
} }

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 type { Preferences, ExpansionPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands" import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
import { import {
activeParentSessionId, activeParentSessionId,
activeSessionId as activeSessionMap, activeSessionId as activeSessionMap,
@@ -13,13 +14,17 @@ import {
import { setSessionCompactionState } from "../../stores/session-compaction" import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
export interface UseCommandsOptions { export interface UseCommandsOptions {
preferences: Accessor<Preferences> preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void toggleShowThinkingBlocks: () => void
toggleUsageMetrics: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
handleNewInstanceRequest: () => void handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void> handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void> handleNewSession: (instanceId: string) => Promise<void>
@@ -28,6 +33,18 @@ export interface UseCommandsOptions {
getActiveSessionIdForInstance: () => string | null 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) { export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry() const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([]) const [commands, setCommands] = createSignal<Command[]>([])
@@ -231,30 +248,33 @@ export function useCommands(options: UseCommandsOptions) {
const session = sessions.find((s) => s.id === sessionId) const session = sessions.find((s) => s.id === sessionId)
if (!session) return if (!session) return
let after = 0 const store = messageStoreBus.getOrCreate(instance.id)
const revert = session.revert 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) { const revertState = store.getSessionRevert(sessionId) ?? session.revert
for (let i = session.messages.length - 1; i >= 0; i--) { let after = 0
const msg = session.messages[i] if (revertState?.messageID) {
const info = session.messagesInfo.get(msg.id) const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID)
if (info?.id === revert.messageID) { after = revertInfo?.time?.created || 0
after = info.time?.created || 0
break
}
}
} }
let messageID = "" let messageID = ""
for (let i = session.messages.length - 1; i >= 0; i--) { let restoredText: string | null = null
const msg = session.messages[i] for (let i = messageIds.length - 1; i >= 0; i--) {
const info = session.messagesInfo.get(msg.id) const id = messageIds[i]
const record = store.getMessage(id)
if (msg.type === "user" && info?.time?.created) { const info = infoMap.get(id) ?? store.getMessageInfo(id)
if (record?.role === "user" && info?.time?.created) {
if (after > 0 && info.time.created >= after) { if (after > 0 && info.time.created >= after) {
continue continue
} }
messageID = msg.id messageID = id
restoredText = extractUserTextFromRecord(record)
break break
} }
} }
@@ -273,18 +293,17 @@ export function useCommands(options: UseCommandsOptions) {
body: { messageID }, body: { messageID },
}) })
const revertedMessage = session.messages.find((m) => m.id === messageID) if (!restoredText) {
const revertedInfo = session.messagesInfo.get(messageID) const fallbackRecord = store.getMessage(messageID)
restoredText = extractUserTextFromRecord(fallbackRecord)
}
if (revertedMessage && revertedInfo?.role === "user") { if (restoredText) {
const textParts = revertedMessage.parts.filter((p) => p.type === "text") const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textParts.length > 0) { if (textarea) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement textarea.value = restoredText
if (textarea) { textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.value = textParts.map((p: any) => p.text).join("\n") textarea.focus()
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
} }
} }
} catch (error) { } catch (error) {
@@ -367,10 +386,26 @@ export function useCommands(options: UseCommandsOptions) {
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`, label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
description: "Show/hide AI thinking process", description: "Show/hide AI thinking process",
category: "System", category: "System",
keywords: ["/thinking", "toggle", "show", "hide"], keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
action: options.toggleShowThinkingBlocks, 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({ commandRegistry.register({
id: "diff-view-split", id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`, label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
@@ -421,9 +456,22 @@ export function useCommands(options: UseCommandsOptions) {
}, },
}) })
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
},
description: "Show or hide token and cost stats for assistant messages",
category: "System",
keywords: ["token", "usage", "cost", "stats"],
action: options.toggleUsageMetrics,
})
commandRegistry.register({ commandRegistry.register({
id: "help", id: "help",
label: "Show Help", label: "Show Help",
description: "Display keyboard shortcuts and help", description: "Display keyboard shortcuts and help",
category: "System", category: "System",
keywords: ["/help", "shortcuts", "help"], keywords: ["/help", "shortcuts", "help"],

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

@@ -0,0 +1,39 @@
import type { NativeDialogOptions } from "../native-functions"
interface ElectronDialogResult {
canceled?: boolean
paths?: string[]
path?: string | null
}
interface ElectronAPI {
openDialog?: (options: NativeDialogOptions) => Promise<ElectronDialogResult>
}
function coerceFirstPath(result?: ElectronDialogResult | null): string | null {
if (!result || result.canceled) {
return null
}
const paths = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : []
if (paths.length === 0) {
return null
}
return paths[0] ?? null
}
export async function openElectronNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (!api?.openDialog) {
return null
}
try {
const result = await api.openDialog(options)
return coerceFirstPath(result)
} catch (error) {
console.error("[native] electron dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,37 @@
import { runtimeEnv } from "../runtime-env"
import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) {
case "electron":
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
}
}
export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler()
if (!handler) {
return null
}
return handler(options)
}
export async function openNativeFolderDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "directory", ...(options ?? {}) })
}
export async function openNativeFileDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "file", ...(options ?? {}) })
}

View File

@@ -0,0 +1,55 @@
import type { NativeDialogOptions } from "../native-functions"
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try {
const response = await dialogApi.open({
title: options.title,
defaultPath: options.defaultPath,
directory: options.mode === "directory",
multiple: false,
filters: options.filters?.map((filter) => ({
name: filter.name,
extensions: filter.extensions,
})),
})
if (!response) {
return null
}
if (Array.isArray(response)) {
return response[0] ?? null
}
return response
} catch (error) {
console.error("[native] tauri dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,13 @@
export type NativeDialogMode = "directory" | "file"
export interface NativeDialogFilter {
name?: string
extensions: string[]
}
export interface NativeDialogOptions {
mode: NativeDialogMode
title?: string
defaultPath?: string
filters?: NativeDialogFilter[]
}

View File

@@ -0,0 +1,86 @@
export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile"
export interface RuntimeEnvironment {
host: HostRuntime
platform: PlatformKind
}
declare global {
interface Window {
electronAPI?: unknown
__TAURI__?: {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
}
}
}
function detectHost(): HostRuntime {
if (typeof window === "undefined") {
return "web"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") {
return "electron"
}
if (typeof win.__TAURI__ !== "undefined") {
return "tauri"
}
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
return "tauri"
}
return "web"
}
function detectPlatform(): PlatformKind {
if (typeof navigator === "undefined") {
return "desktop"
}
const uaData = (navigator as any).userAgentData
if (uaData?.mobile) {
return "mobile"
}
const ua = navigator.userAgent.toLowerCase()
if (/android|iphone|ipad|ipod|blackberry|mini|windows phone|mobile|silk/.test(ua)) {
return "mobile"
}
return "desktop"
}
let cachedEnv: RuntimeEnvironment | null = null
export function detectRuntimeEnvironment(): RuntimeEnvironment {
if (cachedEnv) {
return cachedEnv
}
cachedEnv = {
host: detectHost(),
platform: detectPlatform(),
}
if (typeof console !== "undefined") {
const message = `[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`
console.info(message)
}
return cachedEnv
}
export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri"
export const isWebHost = () => runtimeEnv.host === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"

View File

@@ -14,16 +14,15 @@ import type {
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { CODENOMAD_API_BASE } from "./api-client" import { serverEvents } from "./server-events"
import type {
InstanceStreamEvent,
InstanceStreamStatus,
WorkspaceEventPayload,
} from "../../../server/src/api-types"
interface SSEConnection { type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
instanceId: string type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
proxyPath: string
eventSource: EventSource
status: "connecting" | "connected" | "disconnected" | "error"
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
}
interface TuiToastEvent { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
@@ -35,7 +34,7 @@ interface TuiToastEvent {
} }
} }
type SSEEvent = type SSEEvent =
| MessageUpdateEvent | MessageUpdateEvent
| MessageRemovedEvent | MessageRemovedEvent
| MessagePartUpdatedEvent | MessagePartUpdatedEvent
@@ -48,73 +47,43 @@ type SSEEvent =
| EventPermissionReplied | EventPermissionReplied
| EventLspUpdated | EventLspUpdated
| TuiToastEvent | TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types | { type: string; properties?: Record<string, unknown> }
const [connectionStatus, setConnectionStatus] = createSignal< type ConnectionStatus = InstanceStreamStatus
Map<string, "connecting" | "connected" | "disconnected" | "error">
>(new Map()) const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
class SSEManager { class SSEManager {
private connections = new Map<string, SSEConnection>() constructor() {
private static readonly MAX_RECONNECT_DELAY_MS = 5000 serverEvents.on("instance.eventStatus", (event) => {
const payload = event as InstanceStatusPayload
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void { this.updateConnectionStatus(payload.instanceId, payload.status)
const existing = this.connections.get(instanceId) if (payload.status === "disconnected") {
if (existing) { if (payload.reason === "workspace stopped") {
this.clearReconnectTimer(existing) return
existing.eventSource.close() }
} const reason = payload.reason ?? "Instance disconnected"
void this.onConnectionLost?.(payload.instanceId, reason)
const url = buildInstanceEventsUrl(proxyPath)
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
proxyPath,
eventSource,
status: "connecting",
reconnectAttempts,
}
this.connections.set(instanceId, connection)
this.updateConnectionStatus(instanceId, "connecting")
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
this.updateConnectionStatus(instanceId, "connected")
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
} }
} })
eventSource.onerror = () => { serverEvents.on("instance.event", (event) => {
connection.status = "error" const payload = event as InstanceEventPayload
this.updateConnectionStatus(instanceId, "error") this.updateConnectionStatus(payload.instanceId, "connected")
console.error(`[SSE] Connection error for instance ${instanceId}`) this.handleEvent(payload.instanceId, payload.event as SSEEvent)
this.handleConnectionError(instanceId, "Connection to instance lost") })
}
} }
disconnect(instanceId: string): void { seedStatus(instanceId: string, status: ConnectionStatus) {
const connection = this.connections.get(instanceId) this.updateConnectionStatus(instanceId, status)
if (connection) {
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
} }
private handleEvent(instanceId: string, event: SSEEvent): void { private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
console.warn("[SSE] Dropping malformed event", event)
return
}
console.log("[SSE] Received event:", event.type, event) console.log("[SSE] Received event:", event.type, event)
switch (event.type) { switch (event.type) {
@@ -159,35 +128,7 @@ class SSEManager {
} }
} }
private handleConnectionError(instanceId: string, reason: string): void { private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
const connection = this.connections.get(instanceId)
if (!connection) return
connection.eventSource.close()
const nextAttempt = connection.reconnectAttempts + 1
const delay = Math.min(nextAttempt * 1000, SSEManager.MAX_RECONNECT_DELAY_MS)
connection.reconnectAttempts = nextAttempt
connection.status = "connecting"
this.updateConnectionStatus(instanceId, "connecting")
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
this.connect(instanceId, connection.proxyPath, nextAttempt)
}, delay)
}
private clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
setConnectionStatus((prev) => { setConnectionStatus((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instanceId, status) next.set(instanceId, status)
@@ -209,7 +150,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void> onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null { getStatus(instanceId: string): ConnectionStatus | null {
return connectionStatus().get(instanceId) ?? null return connectionStatus().get(instanceId) ?? null
} }
@@ -218,19 +159,4 @@ class SSEManager {
} }
} }
function buildInstanceEventsUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/event`
}
function normalizeProxyPath(proxyPath: string): string {
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
}
function stripTrailingSlashes(input: string): string {
return input.replace(/\/+$/, "")
}
export const sseManager = new SSEManager() export const sseManager = new SSEManager()

View File

@@ -21,14 +21,11 @@ function applyTheme(dark: boolean) {
export function ThemeProvider(props: { children: JSX.Element }) { export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)") const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const { themePreference, setThemePreference } = useConfig() const { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(false) const [isDark, setIsDarkSignal] = createSignal(true)
const resolveDarkTheme = () => { const resolveDarkTheme = () => {
const preference = themePreference() themePreference()
if (preference === "system") { return true
return systemPrefersDark.matches
}
return preference === "dark"
} }
const applyResolvedTheme = () => { const applyResolvedTheme = () => {
@@ -42,11 +39,8 @@ export function ThemeProvider(props: { children: JSX.Element }) {
}) })
onMount(() => { onMount(() => {
const handleSystemThemeChange = (event: MediaQueryListEvent) => { const handleSystemThemeChange = () => {
if (themePreference() === "system") { applyResolvedTheme()
setIsDarkSignal(event.matches)
applyTheme(event.matches)
}
} }
systemPrefersDark.addEventListener("change", handleSystemThemeChange) systemPrefersDark.addEventListener("change", handleSystemThemeChange)
@@ -56,12 +50,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
} }
}) })
const setTheme = (dark: boolean) => { const setTheme = (_dark: boolean) => {
setThemePreference(dark ? "dark" : "light") setThemePreference("dark")
} }
const toggleTheme = () => { const toggleTheme = () => {
setTheme(!isDark()) setTheme(true)
} }
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider> return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>

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

@@ -3,6 +3,7 @@ import App from "./App"
import { ThemeProvider } from "./lib/theme" import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config" import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -12,6 +13,11 @@ if (!root) {
throw new Error("Root element not found") throw new Error("Root element not found")
} }
if (typeof document !== "undefined") {
document.documentElement.dataset.runtimeHost = runtimeEnv.host
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
}
render( render(
() => ( () => (
<ConfigProvider> <ConfigProvider>

View File

@@ -6,30 +6,18 @@
<title>CodeNomad</title> <title>CodeNomad</title>
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: dark;
} }
/* html, html,
body { body {
background-color: #ffffff; background-color: #1a1a1a;
color: #1a1a1a; color: #e0e0e0;
} }
@media (prefers-color-scheme: dark) { */
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
/* } */
</style> </style>
<script> <script>
;(function () { ;(function () {
try { try {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches document.documentElement.setAttribute('data-theme', 'dark')
// if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark')
// } else {
// document.documentElement.removeAttribute('data-theme')
// }
} catch (error) { } catch (error) {
console.warn('Failed to apply initial theme', error) console.warn('Failed to apply initial theme', error)
} }

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<script>
;(function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
console.warn('Failed to apply initial theme', error)
}
})()
</script>
</head>
<body>
<div id="loading-root"></div>
<script type="module" src="./loading/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--surface-base, #0f141f);
color: var(--text-primary, #cfd4dc);
font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.loading-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
max-width: 520px;
width: 100%;
text-align: center;
}
.loading-logo {
width: 180px;
height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
}
.loading-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.loading-title {
font-size: 2.8rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #f4f6fb);
}
.loading-status {
margin: 0;
font-size: 1rem;
color: var(--text-muted, #aeb3c4);
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
}
.spinner {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: var(--text-muted, #8f96a9);
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.loading-error {
margin-top: 12px;
color: #ff9ea9;
font-size: 0.95rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,166 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css"
import "./loading.css"
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…",
]
interface CliStatus {
state?: string
url?: string | null
error?: string | null
}
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
}
function pickPhrase(previous?: string) {
const filtered = phrases.filter((phrase) => phrase !== previous)
const source = filtered.length > 0 ? filtered : phrases
const index = Math.floor(Math.random() * source.length)
return source[index]
}
function navigateTo(url?: string | null) {
if (!url) return
window.location.replace(url)
}
function getTauriBridge(): TauriBridge | null {
if (typeof window === "undefined") {
return null
}
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
if (!bridge || !bridge.event || !bridge.invoke) {
return null
}
return bridge
}
function annotateDocument() {
if (typeof document === "undefined") {
return
}
document.documentElement.dataset.runtimeHost = runtimeEnv.host
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
}
function LoadingApp() {
const [phrase, setPhrase] = createSignal(pickPhrase())
const [error, setError] = createSignal<string | null>(null)
const [status, setStatus] = createSignal<string | null>(null)
const changePhrase = () => setPhrase(pickPhrase(phrase()))
onMount(() => {
annotateDocument()
setPhrase(pickPhrase())
const unsubscribers: Array<() => void> = []
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
return
}
try {
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {}
setError(null)
setStatus(null)
navigateTo(payload.url)
})
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.error) {
setError(payload.error)
setStatus("Encountered an issue")
}
})
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) {
setError(payload.error)
setStatus("Encountered an issue")
return
}
if (payload.state && payload.state !== "ready") {
setError(null)
setStatus(null)
}
})
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
if (result?.state === "ready" && result.url) {
navigateTo(result.url)
} else if (result?.state === "error" && result.error) {
setError(result.error)
setStatus("Encountered an issue")
}
} catch (err) {
setError(String(err))
setStatus("Encountered an issue")
}
}
if (isTauriHost()) {
void bootstrapTauri(getTauriBridge())
}
onCleanup(() => {
unsubscribers.forEach((unsubscribe) => {
try {
unsubscribe()
} catch {
/* noop */
}
})
})
})
return (
<div class="loading-wrapper" role="status" aria-live="polite">
<img src={iconUrl} alt="CodeNomad" class="loading-logo" width="180" height="180" />
<div class="loading-heading">
<h1 class="loading-title">CodeNomad</h1>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true" />
<span>{phrase()}</span>
</div>
<div class="phrase-controls">
<button type="button" onClick={changePhrase}>
Show another
</button>
</div>
{error() && <div class="loading-error">{error()}</div>}
</div>
</div>
)
}
const root = document.getElementById("loading-root")
if (!root) {
throw new Error("Loading root element not found")
}
render(() => <LoadingApp />, root)

View File

@@ -1,7 +1,8 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { produce } from "solid-js/store"
import type { Instance, LogEntry } from "../types/instance" import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus, Permission } from "@opencode-ai/sdk" 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 { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager" import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
@@ -12,14 +13,15 @@ import {
fetchSessions, fetchSessions,
fetchAgents, fetchAgents,
fetchProviders, fetchProviders,
removeSessionIndexes,
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
} from "./sessions" } from "./sessions"
import { fetchCommands, clearCommands } from "./commands" import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { computeDisplayParts } from "./session-messages" import { setSessionPendingPermission } from "./session-state"
import { withSession, setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui" 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()) const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
@@ -52,7 +54,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
error: descriptor.error, error: descriptor.error,
client: existing?.client ?? null, client: existing?.client ?? null,
metadata: existing?.metadata, metadata: existing?.metadata,
binaryPath: descriptor.binaryLabel, binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
binaryLabel: descriptor.binaryLabel,
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
} }
} }
@@ -87,7 +91,6 @@ function attachClient(descriptor: WorkspaceDescriptor) {
if (instance.client) { if (instance.client) {
sdkManager.destroyClient(descriptor.id) sdkManager.destroyClient(descriptor.id)
sseManager.disconnect(descriptor.id)
} }
const client = sdkManager.createClient(descriptor.id, nextProxyPath) const client = sdkManager.createClient(descriptor.id, nextProxyPath)
@@ -97,7 +100,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
proxyPath: nextProxyPath, proxyPath: nextProxyPath,
status: "ready", status: "ready",
}) })
sseManager.connect(descriptor.id, nextProxyPath) sseManager.seedStatus(descriptor.id, "connecting")
void hydrateInstanceData(descriptor.id).catch((error) => { void hydrateInstanceData(descriptor.id).catch((error) => {
console.error("Failed to hydrate instance data", error) console.error("Failed to hydrate instance data", error)
}) })
@@ -110,7 +113,7 @@ function releaseInstanceResources(instanceId: string) {
if (instance.client) { if (instance.client) {
sdkManager.destroyClient(instanceId) sdkManager.destroyClient(instanceId)
} }
sseManager.disconnect(instanceId) sseManager.seedStatus(instanceId, "disconnected")
} }
async function hydrateInstanceData(instanceId: string) { async function hydrateInstanceData(instanceId: string) {
@@ -293,7 +296,8 @@ function removeInstance(id: string) {
} }
// Clean up session indexes and drafts for removed instance // Clean up session indexes and drafts for removed instance
removeSessionIndexes(id) clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id) clearInstanceDraftPrompts(id)
} }
@@ -538,23 +542,49 @@ function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID return (permission as any).sessionID
} }
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null { function getPermissionMessageId(permission: Permission): string | undefined {
const expectedCallId = permission.callID return (permission as any).messageID ?? (permission as any).messageId ?? undefined
for (const part of message.parts) { }
if (part.type !== "tool") continue
const toolCallId = (part as any).callID 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 (expectedCallId) {
if (toolCallId === expectedCallId) { if (toolCallId === expectedCallId) {
return part as ClientPart return { partId, part }
} }
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) { if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) {
return part as ClientPart return { partId, part }
} }
continue continue
} }
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) { if (
return part as ClientPart (toolCallId && toolCallId === permissionId) ||
part.id === permissionId ||
(permissionMessageId && partMessageId === permissionMessageId)
) {
return { partId, part }
} }
} }
return null return null
@@ -563,23 +593,31 @@ function findToolPartForPermission(message: Message, permission: Permission): Cl
function mutateToolPartPermission( function mutateToolPartPermission(
instanceId: string, instanceId: string,
permission: Permission, permission: Permission,
mutator: (part: ClientPart, message: Message) => boolean, mutator: (part: ClientPart) => boolean,
): void { ): void {
const permissionSessionId = getPermissionSessionId(permission) const messageId = getPermissionMessageId(permission)
withSession(instanceId, permissionSessionId, (session) => { if (!messageId) return
const message = session.messages.find((msg) => msg.id === permission.messageID) const store = messageStoreBus.getOrCreate(instanceId)
if (!message) return const messageRecord = store.getMessage(messageId)
const targetPart = findToolPartForPermission(message, permission) if (!messageRecord) return
if (!targetPart) return const targetPart = findToolPartForPermission(messageRecord, permission)
if (!targetPart) return
const changed = mutator(targetPart, message) store.setState(
if (!changed) return "messages",
messageId,
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1 produce((draft: MessageRecord) => {
targetPart.version = nextPartVersion const partRecord = draft.parts[targetPart.partId]
message.version = (message.version ?? 0) + 1 if (!partRecord) return
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) 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 { 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,12 +29,14 @@ export type ExpansionPreference = "expanded" | "collapsed"
export interface Preferences { export interface Preferences {
showThinkingBlocks: boolean showThinkingBlocks: boolean
thinkingBlocksExpansion: ExpansionPreference
lastUsedBinary?: string lastUsedBinary?: string
environmentVariables: Record<string, string> environmentVariables: Record<string, string>
modelRecents: ModelPreference[] modelRecents: ModelPreference[]
diffViewMode: DiffViewMode diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference
showUsageMetrics: boolean
} }
export interface OpenCodeBinary { export interface OpenCodeBinary {
@@ -55,11 +57,13 @@ const MAX_RECENT_MODELS = 5
const defaultPreferences: Preferences = { const defaultPreferences: Preferences = {
showThinkingBlocks: false, showThinkingBlocks: false,
thinkingBlocksExpansion: "expanded",
environmentVariables: {}, environmentVariables: {},
modelRecents: [], modelRecents: [],
diffViewMode: "split", diffViewMode: "split",
toolOutputExpansion: "expanded", toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded", diagnosticsExpansion: "expanded",
showUsageMetrics: true,
} }
function deepEqual(a: unknown, b: unknown): boolean { function deepEqual(a: unknown, b: unknown): boolean {
@@ -86,12 +90,14 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
return { return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables, environmentVariables,
modelRecents, modelRecents,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
} }
} }
@@ -266,10 +272,19 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void {
updatePreferences({ diagnosticsExpansion: mode }) updatePreferences({ diagnosticsExpansion: mode })
} }
function setThinkingBlocksExpansion(mode: ExpansionPreference): void {
if (preferences().thinkingBlocksExpansion === mode) return
updatePreferences({ thinkingBlocksExpansion: mode })
}
function toggleShowThinkingBlocks(): void { function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
} }
function toggleUsageMetrics(): void {
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
}
function addRecentFolder(path: string): void { function addRecentFolder(path: string): void {
updateConfig((draft) => { updateConfig((draft) => {
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
@@ -370,9 +385,11 @@ interface ConfigContextValue {
setThemePreference: typeof setThemePreference setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleUsageMetrics: typeof toggleUsageMetrics
setDiffViewMode: typeof setDiffViewMode setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
addRecentFolder: typeof addRecentFolder addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary addOpenCodeBinary: typeof addOpenCodeBinary
@@ -400,9 +417,11 @@ const configContextValue: ConfigContextValue = {
setThemePreference, setThemePreference,
updateConfig, updateConfig,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion,
addRecentFolder, addRecentFolder,
removeRecentFolder, removeRecentFolder,
addOpenCodeBinary, addOpenCodeBinary,
@@ -454,6 +473,7 @@ export {
updateConfig, updateConfig,
updatePreferences, updatePreferences,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleUsageMetrics,
recentFolders, recentFolders,
addRecentFolder, addRecentFolder,
removeRecentFolder, removeRecentFolder,
@@ -470,7 +490,9 @@ export {
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion,
themePreference, themePreference,
setThemePreference, setThemePreference,
recordWorkspaceLaunch, recordWorkspaceLaunch,
} }

View File

@@ -1,21 +1,11 @@
import type { Message } from "../types/message"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances" import { instances } from "./instances"
import { import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
addRecentModelPreference,
preferences,
setAgentModelPreference,
} from "./preferences"
import { sessions, withSession } from "./session-state" import { sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models" import { getDefaultModel, isModelValid } from "./session-models"
import { import { updateSessionInfo } from "./message-v2/session-info"
computeDisplayParts, import { messageStoreBus } from "./message-v2/bus"
getSessionIndex,
initializePartVersion,
updateSessionInfo,
} from "./session-messages"
const ID_LENGTH = 26 const ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 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[] = [ const requestParts: any[] = [
{ {
id: textPartId, 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 = { const requestBody = {
messageID: messageId, messageID: messageId,
parts: requestParts, parts: requestParts,

View File

@@ -2,7 +2,7 @@ import type { Session } from "../types/session"
import type { Message } from "../types/message" import type { Message } from "../types/message"
import { instances, refreshPermissionsForSession } from "./instances" import { instances, refreshPermissionsForSession } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences" import { setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction" import { setSessionCompactionState } from "./session-compaction"
import { import {
activeSessionId, activeSessionId,
@@ -21,16 +21,12 @@ import {
loading, loading,
setLoading, setLoading,
} from "./session-state" } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { import { normalizeMessagePart } from "./message-v2/normalizers"
computeDisplayParts, import { updateSessionInfo } from "./message-v2/session-info"
clearSessionIndex, import { seedSessionMessagesV2 } from "./message-v2/bridge"
getSessionIndex, import { messageStoreBus } from "./message-v2/bus"
initializePartVersion, import { clearCacheForSession } from "../lib/global-cache"
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
} from "./session-messages"
interface SessionForkResponse { interface SessionForkResponse {
id: string id: string
@@ -99,8 +95,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
diff: apiSession.revert.diff, diff: apiSession.revert.diff,
} }
: undefined, : undefined,
messages: existingSession?.messages ?? [],
messagesInfo: existingSession?.messagesInfo ?? new Map(),
}) })
} }
@@ -195,8 +189,6 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
diff: response.data.revert.diff, diff: response.data.revert.diff,
} }
: undefined, : undefined,
messages: [],
messagesInfo: new Map(),
} }
setSessions((prev) => { setSessions((prev) => {
@@ -212,25 +204,30 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0 const initialContextWindow = initialModel?.limit?.context ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialContextPercent = initialContextWindow > 0 ? 0 : null const initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
setSessionInfoByInstance((prev) => { setSessionInfoByInstance((prev) => {
const next = new Map(prev) const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId)) const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, { instanceInfo.set(session.id, {
tokens: 0,
cost: 0, cost: 0,
contextWindow: initialContextWindow, contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel), isSubscriptionModel: Boolean(initialSubscriptionModel),
contextUsageTokens: 0, inputTokens: 0,
contextUsagePercent: initialContextPercent, outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: initialOutputLimit,
contextAvailableTokens: initialContextAvailable,
}) })
next.set(instanceId, instanceInfo) next.set(instanceId, instanceInfo)
return next return next
}) })
getSessionIndex(instanceId, session.id)
return session return session
} catch (error) { } catch (error) {
console.error("Failed to create session:", error) console.error("Failed to create session:", error)
@@ -293,8 +290,6 @@ async function forkSession(
diff: info.revert.diff, diff: info.revert.diff,
} }
: undefined, : undefined,
messages: [],
messagesInfo: new Map(),
} as unknown as Session } as unknown as Session
setSessions((prev) => { setSessions((prev) => {
@@ -310,25 +305,28 @@ async function forkSession(
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0 const forkContextWindow = forkModel?.limit?.context ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkContextPercent = forkContextWindow > 0 ? 0 : null const forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
setSessionInfoByInstance((prev) => { setSessionInfoByInstance((prev) => {
const next = new Map(prev) const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId)) const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, { instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0, cost: 0,
contextWindow: forkContextWindow, contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel), isSubscriptionModel: Boolean(forkSubscriptionModel),
contextUsageTokens: 0, inputTokens: 0,
contextUsagePercent: forkContextPercent, outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: forkOutputLimit,
contextAvailableTokens: forkContextAvailable,
}) })
next.set(instanceId, instanceInfo) next.set(instanceId, instanceInfo)
return next return next
}) })
getSessionIndex(instanceId, forkedSession.id)
return forkedSession return forkedSession
} }
@@ -362,6 +360,10 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
setSessionCompactionState(instanceId, sessionId, false) setSessionCompactionState(instanceId, sessionId, false)
clearSessionDraftPrompt(instanceId, sessionId) clearSessionDraftPrompt(instanceId, sessionId)
// Drop normalized message state and caches for this session
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
clearCacheForSession(instanceId, sessionId)
setSessionInfoByInstance((prev) => { setSessionInfoByInstance((prev) => {
const next = new Map(prev) const next = new Map(prev)
const instanceInfo = next.get(instanceId) const instanceInfo = next.get(instanceId)
@@ -377,8 +379,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
return next return next
}) })
clearSessionIndex(instanceId, sessionId)
if (activeSessionId().get(instanceId) === sessionId) { if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => { setActiveSessionId((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -508,8 +508,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
}) })
try { try {
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId }) console.log(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const response = await instance.client.session.messages({ path: { id: sessionId } }) const response = await instance.client.session["messages"]({ path: { id: sessionId } })
if (!response.data || !Array.isArray(response.data)) { if (!response.data || !Array.isArray(response.data)) {
return return
@@ -535,10 +535,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
version: 0, version: 0,
} }
parts.forEach((part: any) => initializePartVersion(part))
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
return message return message
}) })
@@ -573,8 +569,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
if (existingSession) { if (existingSession) {
const updatedSession = { const updatedSession = {
...existingSession, ...existingSession,
messages,
messagesInfo,
agent: agentName || existingSession.agent, agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
} }
@@ -586,8 +580,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next return next
}) })
rebuildSessionIndex(instanceId, sessionId, messages)
setMessagesLoaded((prev) => { setMessagesLoaded((prev) => {
const next = new Map(prev) const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set() const loadedSet = next.get(instanceId) || new Set()
@@ -595,6 +587,15 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
next.set(instanceId, loadedSet) next.set(instanceId, loadedSet)
return next 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) { } catch (error) {
console.error("Failed to load messages:", error) console.error("Failed to load messages:", error)
throw error throw error
@@ -608,17 +609,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next return next
}) })
} }
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
export { export {
createSession, createSession,
deleteSession, deleteSession,
fetchAgents, fetchAgents,
fetchProviders, fetchProviders,
fetchSessions, fetchSessions,
forkSession, forkSession,
loadMessages, loadMessages,

View File

@@ -1,4 +1,5 @@
import type { import type {
MessageInfo,
MessagePartRemovedEvent, MessagePartRemovedEvent,
MessagePartUpdatedEvent, MessagePartUpdatedEvent,
MessageRemovedEvent, MessageRemovedEvent,
@@ -12,9 +13,9 @@ import type {
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { MessageStatus } from "./message-v2/types"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences } from "./preferences"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances" import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { import {
@@ -22,17 +23,20 @@ import {
setSessions, setSessions,
withSession, withSession,
} from "./session-state" } from "./session-state"
import { import { normalizeMessagePart } from "./message-v2/normalizers"
bumpPartVersion, import { updateSessionInfo } from "./message-v2/session-info"
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
} from "./session-messages"
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction" 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 { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
@@ -46,6 +50,30 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) 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 { function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return if (!instanceSessions) return
@@ -55,264 +83,92 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (!rawPart) return if (!rawPart) return
const part = normalizeMessagePart(rawPart) 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 if (!session) return
const index = getSessionIndex(instanceId, part.sessionID) const store = messageStoreBus.getOrCreate(instanceId)
let messageIndex = index.messageIndex.get(part.messageID) const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
let replacedTemp = false const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
if (messageIndex === undefined) { let record = store.getMessage(messageId)
for (let i = 0; i < session.messages.length; i++) { if (!record) {
const msg = session.messages[i] const pendingId = findPendingMessageId(store, sessionId, role)
if (msg.sessionId === part.sessionID && msg.status === "sending") { if (pendingId && pendingId !== messageId) {
messageIndex = i replaceMessageIdV2(instanceId, pendingId, messageId)
replacedTemp = true record = store.getMessage(messageId)
break
}
} }
} }
if (messageIndex === undefined) { if (!record) {
const newMessage: any = { store.upsertMessage({
id: part.messageID, id: messageId,
sessionId: part.sessionID, sessionId,
type: "assistant" as const, role,
parts: [part], status: "streaming",
timestamp: Date.now(), createdAt,
status: "streaming" as const, updatedAt: createdAt,
version: 0, isEphemeral: true,
} })
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)
}
} }
withSession(instanceId, part.sessionID, () => { if (messageInfo) {
/* mutations already applied above */ upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}) }
updateSessionInfo(instanceId, part.sessionID) applyPartUpdateV2(instanceId, part)
refreshPermissionsForSession(instanceId, part.sessionID)
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
} else if (event.type === "message.updated") { } else if (event.type === "message.updated") {
const info = event.properties?.info const info = event.properties?.info
if (!info) return 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 if (!session) return
const index = getSessionIndex(instanceId, info.sessionID) const store = messageStoreBus.getOrCreate(instanceId)
let messageIndex = index.messageIndex.get(info.id) 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 record = store.getMessage(messageId)
let tempMessageIndex = -1 if (!record) {
for (let i = 0; i < session.messages.length; i++) { const pendingId = findPendingMessageId(store, sessionId, role)
const msg = session.messages[i] if (pendingId && pendingId !== messageId) {
if ( replaceMessageIdV2(instanceId, pendingId, messageId)
msg.sessionId === info.sessionID && record = store.getMessage(messageId)
msg.type === (info.role === "user" ? "user" : "assistant") &&
msg.status === "sending"
) {
tempMessageIndex = i
break
}
} }
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) if (!record) {
withSession(instanceId, info.sessionID, () => { const createdAt = info.time?.created ?? Date.now()
/* ensure reactivity */ const completedAt = (info.time as { completed?: number } | undefined)?.completed
}) store.upsertMessage({
id: messageId,
sessionId,
role,
status,
createdAt,
updatedAt: completedAt ?? createdAt,
})
}
updateSessionInfo(instanceId, info.sessionID) upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
refreshPermissionsForSession(instanceId, info.sessionID)
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
} }
} }
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info const info = event.properties?.info
@@ -345,8 +201,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
}, },
messages: [],
messagesInfo: new Map(),
} as any } as any
setSessions((prev) => { setSessions((prev) => {
@@ -356,6 +210,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
next.set(instanceId, updated) next.set(instanceId, updated)
return next return next
}) })
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
console.log(`[SSE] New session created: ${info.id}`, newSession) console.log(`[SSE] New session created: ${info.id}`, newSession)
} else { } else {
@@ -388,6 +243,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
next.set(instanceId, updated) next.set(instanceId, updated)
return next return next
}) })
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
} }
} }
@@ -487,6 +343,7 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`) console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
addPermissionToQueue(instanceId, permission) addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
} }
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void { function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
@@ -495,6 +352,7 @@ function handlePermissionReplied(instanceId: string, event: EventPermissionRepli
console.log(`[SSE] Permission replied: ${permissionID}`) console.log(`[SSE] Permission replied: ${permissionID}`)
removePermissionFromQueue(instanceId, permissionID) removePermissionFromQueue(instanceId, permissionID)
removePermissionV2(instanceId, permissionID)
} }
export { export {

View File

@@ -1,295 +0,0 @@
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { Provider } from "../types/session"
import { decodeHtmlEntities } from "../lib/markdown"
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
interface SessionIndexCache {
messageIndex: Map<string, number>
partIndex: Map<string, Map<string, number>>
}
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
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)
}
}
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.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 tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let actualUsageTokens = 0
let contextUsagePercent: number | null = null
let hasContextUsage = false
if (session.messagesInfo.size > 0) {
const messageArray = Array.from(session.messagesInfo.values()).reverse()
for (const info of messageArray) {
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
if (usage.output > 0) {
const inputTokens = usage.input || 0
const reasoningTokens = usage.reasoning || 0
const cacheReadTokens = usage.cache?.read || 0
const cacheWriteTokens = usage.cache?.write || 0
const outputTokens = usage.output || 0
if (info.summary) {
tokens = outputTokens
} else {
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
}
cost = info.cost || 0
actualUsageTokens = tokens
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break
}
}
}
}
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)
let contextUsageTokens = 0
if (hasContextUsage && actualUsageTokens > 0) {
contextUsageTokens = actualUsageTokens + outputBudget
if (contextWindow > 0) {
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
contextUsagePercent = Math.min(100, Math.max(0, percent))
} else {
contextUsagePercent = null
}
} else {
contextUsagePercent = contextWindow > 0 ? 0 : null
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
contextWindow,
isSubscriptionModel,
contextUsageTokens,
contextUsagePercent,
})
next.set(instanceId, instanceInfo)
return next
})
}
export {
bumpPartVersion,
clearSessionIndex,
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
removeSessionIndexes,
updateSessionInfo,
}

View File

@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session" import type { Session, Agent, Provider } from "../types/session"
export interface SessionInfo { export interface SessionInfo {
tokens: number
cost: number cost: number
contextWindow: number contextWindow: number
isSubscriptionModel: boolean isSubscriptionModel: boolean
contextUsageTokens: number inputTokens: number
contextUsagePercent: number | null outputTokens: number
reasoningTokens: number
actualUsageTokens: number
modelOutputLimit: number
contextAvailableTokens: number | null
} }
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map()) const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
@@ -106,8 +109,6 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
const updatedSession = { const updatedSession = {
...session, ...session,
messages: [...session.messages],
messagesInfo: new Map(session.messagesInfo),
} }
setSessions((prev) => { setSessions((prev) => {

View File

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

View File

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

View File

@@ -6,8 +6,42 @@
.assistant-message { .assistant-message {
/* gap: 0.25rem; */ /* gap: 0.25rem; */
padding: 0.6rem 0.65rem; 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 { .message-queued-badge {
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide; @apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
background-color: var(--accent-primary); background-color: var(--accent-primary);
@@ -101,3 +135,171 @@
.reasoning-label { .reasoning-label {
font-weight: var(--font-weight-medium); 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

@@ -27,12 +27,55 @@
color: var(--text-muted); color: var(--text-muted);
} }
.connection-status-shortcut-action {
@apply flex items-center justify-center gap-2;
}
.connection-status-button {
@apply inline-flex items-center gap-2 px-3 py-1 text-sm font-medium border rounded-md transition-colors;
border-color: var(--border-base);
background-color: var(--surface-base);
color: var(--text-primary);
}
.connection-status-button:hover {
background-color: var(--surface-hover);
}
.connection-status-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.connection-status-shortcut-hint {
@apply inline-flex items-center;
color: var(--text-secondary);
}
@media (pointer: coarse) {
.connection-status-shortcut-hint {
display: none;
}
.connection-status-button {
width: 100%;
justify-content: center;
}
}
.message-stream { .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); background-color: var(--surface-base);
color: inherit; color: inherit;
} }
.message-stream-block {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.message-scroll-button-wrapper { .message-scroll-button-wrapper {
position: absolute; position: absolute;
right: 1rem; right: 1rem;

View File

@@ -9,9 +9,13 @@
@apply flex items-end gap-2 p-3; @apply flex items-end gap-2 p-3;
} }
.prompt-input-field {
position: relative;
width: 100%;
}
.prompt-input { .prompt-input {
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors; @apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-12 border rounded-md text-sm resize-none outline-none transition-colors;
font-family: inherit; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
@@ -19,6 +23,45 @@
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
} }
.prompt-input-overlay {
position: absolute;
bottom: 1rem;
left: 0.75rem;
right: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
line-height: 1.3;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
}
.prompt-input-overlay.shell-mode {
color: var(--text-primary);
}
.prompt-overlay-text {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.prompt-overlay-warning {
color: var(--status-warning);
font-weight: 500;
}
.prompt-overlay-shell-active {
color: var(--status-success);
font-weight: 600;
}
.prompt-overlay-muted {
color: var(--text-muted);
}
.prompt-input.shell-mode { .prompt-input.shell-mode {
border-color: var(--status-success); border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4); box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
@@ -80,9 +123,6 @@
height: 1rem; height: 1rem;
} }
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}
.hint { .hint {
@apply text-xs; @apply text-xs;
@@ -141,3 +181,26 @@
.attachment-download:hover { .attachment-download:hover {
background-color: var(--attachment-chip-ring); background-color: var(--attachment-chip-ring);
} }
@media (pointer: coarse) {
.prompt-input-overlay {
display: none;
}
.prompt-input {
padding-bottom: 1.5rem;
}
}
@media (max-width: 640px) {
.prompt-input {
min-height: 64px;
padding: 0.5rem 0.75rem;
padding-bottom: 2.25rem;
}
.prompt-input-wrapper {
gap: 0.75rem;
padding: 0.75rem;
}
}

View File

@@ -58,6 +58,24 @@ session-sidebar-controls .selector-trigger-primary {
color: var(--text-muted); color: var(--text-muted);
} }
.sidebar-selector-hints {
@apply flex items-center gap-2 w-full;
justify-content: space-between;
}
.sidebar-selector-hint--left,
.sidebar-selector-hint--right {
@apply flex-1;
}
.sidebar-selector-hint--left {
justify-content: flex-start;
}
.sidebar-selector-hint--right {
justify-content: flex-end;
}
.session-header-hints { .session-header-hints {
@apply flex-shrink-0; @apply flex-shrink-0;
} }

Some files were not shown because too many files have changed in this diff Show More