Compare commits
59 Commits
v0.2.1-dev
...
v0.2.5-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cd3cf545 | ||
|
|
042a45db0d | ||
|
|
cc45c16d73 | ||
|
|
91fb351a63 | ||
|
|
d9b149a7cb | ||
|
|
222a467a19 | ||
|
|
18513939f7 | ||
|
|
c123714271 | ||
|
|
5c82a2d653 | ||
|
|
435881529e | ||
|
|
700342670c | ||
|
|
2f40f5eedf | ||
|
|
54905c5626 | ||
|
|
1bf1a4761d | ||
|
|
755695a35a | ||
|
|
6a9a442948 | ||
|
|
3db9b0f673 | ||
|
|
4e0e5dcdca | ||
|
|
fad2809299 | ||
|
|
c77bfc2ee7 | ||
|
|
f1fa28dd2c | ||
|
|
91ace25333 | ||
|
|
b54db28fb1 | ||
|
|
f13feb3062 | ||
|
|
4622bdc7ea | ||
|
|
919127b6d9 | ||
|
|
27cd4515cd | ||
|
|
93a5c16cab | ||
|
|
16b76385e2 | ||
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 |
3
.github/workflows/build-and-upload.yml
vendored
3
.github/workflows/build-and-upload.yml
vendored
@@ -337,9 +337,6 @@ jobs:
|
||||
- name: Ensure rollup native binary
|
||||
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)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-4.6
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
|
||||
@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
|
||||

|
||||

|
||||
_Rich media previews for images and assets._
|
||||
|
||||

|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
|
||||
@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||
│ │ │ - instances[] │ │ │
|
||||
│ │ │ - sessions[] per instance │ │ │
|
||||
│ │ │ - messages[] per session │ │ │
|
||||
│ │ │ - normalized message store per session │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageStream │ │ │
|
||||
│ │ │ - MessageStreamV2 │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
|
||||
@@ -49,7 +49,7 @@ packages/opencode-client/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream.tsx # Messages display
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
@@ -153,16 +153,24 @@ interface Session {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
messages: Message[]
|
||||
status: SessionStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
|
||||
|
Before Width: | Height: | Size: 845 KiB After Width: | Height: | Size: 845 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -8613,7 +8613,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -8641,7 +8641,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -8680,14 +8680,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
@@ -54,7 +55,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: uiRendererEntry,
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
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) {
|
||||
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) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
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("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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,22 +30,63 @@ function getIconPath() {
|
||||
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) {
|
||||
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")
|
||||
if (existsSync(distResources)) {
|
||||
return distResources
|
||||
try {
|
||||
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||
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")
|
||||
if (existsSync(devResources)) {
|
||||
return devResources
|
||||
}
|
||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -116,10 +157,9 @@ function createWindow() {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
const loadingHtml = getLoadingHtmlPath()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
@@ -156,8 +196,7 @@ function showLoadingScreen(force = false) {
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
const loadingHtml = getLoadingHtmlPath()
|
||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
|
||||
@@ -263,47 +263,32 @@ export class CliProcessManager extends EventEmitter {
|
||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||
if (options.dev) {
|
||||
const tsxPath = this.resolveTsx()
|
||||
const sourceCandidates = [
|
||||
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
|
||||
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 }
|
||||
if (!tsxPath) {
|
||||
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||
}
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const dist = this.tryResolveDist()
|
||||
if (dist) {
|
||||
return { entry: dist, runner: "node" }
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
}
|
||||
|
||||
|
||||
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)> = [
|
||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
|
||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
|
||||
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
||||
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
|
||||
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
|
||||
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
||||
() => nodeRequire.resolve("tsx/cli"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||
() => nodeRequire.resolve("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(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) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
@@ -314,7 +299,28 @@ export class CliProcessManager extends EventEmitter {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function setupStorageIPC() {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,15 +5,12 @@ const electronAPI = {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
},
|
||||
onCliLog: (callback) => {
|
||||
ipcRenderer.on("cli:log", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:log")
|
||||
},
|
||||
onCliError: (callback) => {
|
||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -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 AI’s 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -111,6 +111,14 @@ export interface InstanceData {
|
||||
agentModelSelections: AgentModelSelection
|
||||
}
|
||||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
@@ -157,6 +165,8 @@ export type WorkspaceEventType =
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
@@ -167,6 +177,8 @@ export type WorkspaceEventPayload =
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { 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 {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
|
||||
@@ -10,12 +10,14 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
|
||||
@@ -8,7 +8,9 @@ export class EventBus extends EventEmitter {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,6 +24,8 @@ export class EventBus extends EventEmitter {
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
@@ -31,6 +35,8 @@ export class EventBus extends EventEmitter {
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -78,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: parsed.host,
|
||||
host: normalizedHost,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
@@ -100,6 +104,13 @@ function parsePort(input: string): number {
|
||||
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() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
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 instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
@@ -169,6 +185,7 @@ async function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,6 +65,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
app.register(replyFrom, {
|
||||
contentTypesToEncode: [],
|
||||
undici: {
|
||||
connections: 16,
|
||||
pipelining: 1,
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
},
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
|
||||
190
packages/server/src/workspaces/instance-events.ts
Normal file
190
packages/server/src/workspaces/instance-events.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -65,10 +66,11 @@ export class WorkspaceManager {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
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`
|
||||
|
||||
@@ -79,14 +81,20 @@ export class WorkspaceManager {
|
||||
name,
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: binary.id,
|
||||
binaryId: resolvedBinaryPath,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
@@ -95,7 +103,7 @@ export class WorkspaceManager {
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: binary.path,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
@@ -161,6 +169,70 @@ export class WorkspaceManager {
|
||||
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 }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
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, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
|
||||
539
packages/tauri-app/Cargo.lock
generated
539
packages/tauri-app/Cargo.lock
generated
@@ -47,6 +47,61 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -325,6 +380,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 1.0.69",
|
||||
"which",
|
||||
]
|
||||
@@ -339,6 +395,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -577,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
@@ -591,6 +658,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.0"
|
||||
@@ -614,6 +690,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -676,6 +758,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -703,6 +812,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -822,6 +958,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -1656,6 +1805,12 @@ version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1813,6 +1968,19 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -2132,6 +2300,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2157,6 +2335,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2346,7 +2530,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.12.1",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2462,6 +2646,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -2511,6 +2704,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -2531,6 +2734,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -2549,6 +2762,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -2677,6 +2899,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -2695,10 +2942,23 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2771,6 +3031,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2992,6 +3258,15 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
@@ -3086,6 +3361,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -3354,6 +3635,63 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.1"
|
||||
@@ -3455,6 +3793,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
@@ -3557,7 +3908,9 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -3722,9 +4075,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"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]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
@@ -3774,6 +4139,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -4018,6 +4394,66 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web-sys"
|
||||
version = "0.3.82"
|
||||
@@ -4117,7 +4553,7 @@ dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"rustix 0.38.44",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4674,6 +5110,62 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.28"
|
||||
@@ -4747,3 +5239,44 @@ dependencies = [
|
||||
"quote",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"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",
|
||||
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild",
|
||||
"build": "tauri build"
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
|
||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
46
packages/tauri-app/scripts/dev-prep.js
Normal 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()
|
||||
@@ -1,45 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const workspaceRoot = path.resolve(root, "..", "..");
|
||||
const serverRoot = path.resolve(root, "..", "server");
|
||||
const dest = path.resolve(root, "src-tauri", "resources", "server");
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const serverRoot = path.resolve(root, "..", "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 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",
|
||||
)
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist");
|
||||
const publicPath = path.join(serverRoot, "public");
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
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", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
|
||||
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();
|
||||
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const name of sources) {
|
||||
const from = path.join(serverRoot, name);
|
||||
const to = path.join(dest, name);
|
||||
if (!fs.existsSync(from)) {
|
||||
console.warn(`[prebuild] skipped missing ${from}`);
|
||||
continue;
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ui build missing; running workspace build...")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
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 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()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
ensureServerDependencies()
|
||||
copyServerArtifacts()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -17,3 +17,4 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal 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
@@ -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"]}}
|
||||
@@ -2143,6 +2143,72 @@
|
||||
"type": "string",
|
||||
"const": "core:window:deny-unminimize",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2143,6 +2143,72 @@
|
||||
"type": "string",
|
||||
"const": "core:window:deny-unminimize",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -355,7 +355,7 @@ impl CliProcessManager {
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
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) {
|
||||
continue;
|
||||
|
||||
@@ -19,6 +19,7 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
})
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "",
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "../src"
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CodeNomad",
|
||||
"url": "index.html",
|
||||
"url": "loading.html",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
@@ -22,21 +25,23 @@
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true
|
||||
"decorations": true,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1a1a1a"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
}
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"resources": [
|
||||
"../src/index.html",
|
||||
"../src/icon.png",
|
||||
"resources/server"
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -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 AI’s 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -47,9 +47,11 @@ const App: Component = () => {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
@@ -205,9 +207,11 @@ const App: Component = () => {
|
||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||
preferences,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface AgentSelectorProps {
|
||||
instanceId: string
|
||||
@@ -116,9 +115,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
<span class="hint sidebar-selector-hint">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, onMount, createEffect } from "solid-js"
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
@@ -13,7 +14,7 @@ interface ToolCallDiffViewerProps {
|
||||
mode: DiffViewMode
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheKey?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
type DiffData = {
|
||||
@@ -22,6 +23,13 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const diffData = createMemo<DiffData | null>(() => {
|
||||
const normalized = normalizeDiffText(props.diffText)
|
||||
@@ -46,30 +54,93 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
})
|
||||
|
||||
let diffContainerRef: HTMLDivElement | undefined
|
||||
let pendingCapture: number | undefined
|
||||
let pendingContext: CaptureContext | undefined
|
||||
let lastRenderedMarkup: string | undefined
|
||||
let lastCachedHtml: string | undefined
|
||||
|
||||
const captureAndCacheHtml = () => {
|
||||
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
|
||||
// Extract the rendered HTML from DiffView container
|
||||
const renderedHtml = diffContainerRef.innerHTML
|
||||
if (renderedHtml) {
|
||||
setToolRenderCache(props.cacheKey, {
|
||||
text: props.diffText,
|
||||
html: renderedHtml,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
const clearPendingCapture = () => {
|
||||
if (pendingCapture !== undefined) {
|
||||
cancelAnimationFrame(pendingCapture)
|
||||
pendingCapture = undefined
|
||||
}
|
||||
pendingContext = undefined
|
||||
}
|
||||
|
||||
const runCapture = (context: CaptureContext) => {
|
||||
if (!diffContainerRef) {
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) {
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const hasChanged = markup !== lastRenderedMarkup
|
||||
if (hasChanged) {
|
||||
lastRenderedMarkup = markup
|
||||
if (context.cacheEntryParams) {
|
||||
setCacheEntry(context.cacheEntryParams, {
|
||||
text: context.diffText,
|
||||
html: markup,
|
||||
theme: context.theme,
|
||||
mode: context.mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
props.onRendered?.()
|
||||
}
|
||||
|
||||
// Also capture HTML when diff data changes
|
||||
const scheduleCapture = (context: CaptureContext) => {
|
||||
clearPendingCapture()
|
||||
pendingContext = context
|
||||
pendingCapture = requestAnimationFrame(() => {
|
||||
const activeContext = pendingContext
|
||||
pendingContext = undefined
|
||||
pendingCapture = undefined
|
||||
if (activeContext) {
|
||||
runCapture(activeContext)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const data = diffData()
|
||||
if (data && !props.cachedHtml) {
|
||||
// Delay to allow DiffView to re-render with new data
|
||||
setTimeout(captureAndCacheHtml, 100)
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
clearPendingCapture()
|
||||
if (cachedHtml !== lastCachedHtml) {
|
||||
lastCachedHtml = cachedHtml
|
||||
lastRenderedMarkup = cachedHtml
|
||||
props.onRendered?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastCachedHtml = undefined
|
||||
|
||||
const data = diffData()
|
||||
const theme = props.theme
|
||||
const mode = props.mode
|
||||
|
||||
if (!data) {
|
||||
clearPendingCapture()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleCapture({
|
||||
theme,
|
||||
mode,
|
||||
diffText: props.diffText,
|
||||
cacheEntryParams: props.cacheEntryParams,
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearPendingCapture()
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
|
||||
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 [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
const folders = () => recentFolders()
|
||||
@@ -29,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
if (lastUsed && lastUsed !== selectedBinary()) {
|
||||
setSelectedBinary(lastUsed)
|
||||
}
|
||||
if (!lastUsed) return
|
||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||
})
|
||||
|
||||
|
||||
@@ -78,7 +79,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
handleBrowse()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,9 +173,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function handleBrowse() {
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -219,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<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>
|
||||
<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>
|
||||
@@ -306,14 +318,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
@@ -342,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</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">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
@@ -48,6 +48,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||
|
||||
const metadata = () => props.instance.metadata
|
||||
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
|
||||
const mcpServers = () => {
|
||||
const status = metadata()?.mcpStatus
|
||||
return status ? parseMcpStatus(status) : []
|
||||
@@ -104,11 +105,12 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
...(lspStatus ? { lspStatus } : {}),
|
||||
}
|
||||
|
||||
if (!nextMetadata.version) {
|
||||
nextMetadata.version = "0.15.8"
|
||||
if (!nextMetadata.version && instance.binaryVersion) {
|
||||
nextMetadata.version = instance.binaryVersion
|
||||
}
|
||||
|
||||
updateInstance(instanceId, { metadata: nextMetadata })
|
||||
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load instance metadata:", error)
|
||||
@@ -173,13 +175,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={metadata()?.version}>
|
||||
<Show when={binaryVersion()}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
OpenCode Version
|
||||
</div>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
@@ -281,7 +281,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
|
||||
@@ -14,6 +14,7 @@ import InfoView from "../info-view"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import Kbd from "../kbd"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
|
||||
@@ -28,7 +29,7 @@ interface InstanceShellProps {
|
||||
onExecuteCommand: (command: Command) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
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
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
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 type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
record: MessageRecord
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts?: ClientPart[]
|
||||
combinedParts: ClientPart[]
|
||||
orderedParts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const isUser = () => props.record.role === "user"
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
const date = new Date(createdTime)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
@@ -27,10 +31,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const displayParts = () => props.parts ?? props.message.parts
|
||||
const combinedParts = () => props.combinedParts
|
||||
|
||||
const fileAttachments = () =>
|
||||
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
props.orderedParts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
const getAttachmentName = (part: FilePart) => {
|
||||
if (part.filename && part.filename.trim().length > 0) {
|
||||
@@ -120,7 +124,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return displayParts().some((part) => partHasRenderableText(part))
|
||||
return combinedParts().some((part) => partHasRenderableText(part))
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
@@ -130,15 +134,19 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const handleRevert = () => {
|
||||
if (props.onRevert && isUser()) {
|
||||
props.onRevert(props.message.id)
|
||||
props.onRevert(props.record.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerClass = () =>
|
||||
isUser()
|
||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (isUser()) return ""
|
||||
const info = props.messageInfo
|
||||
@@ -155,24 +163,30 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div class={containerClass()}>
|
||||
<div class="flex justify-between items-center gap-2.5 pb-0.5">
|
||||
<div class="flex flex-col">
|
||||
<Show when={isUser()}>
|
||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
||||
</Show>
|
||||
<div class={containerClass()}>
|
||||
<div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||
<div class="flex flex-col">
|
||||
<Show when={isUser()}>
|
||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-[var(--message-assistant-border)]">
|
||||
<span class="font-semibold">Assistant</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<Show when={!isUser()}>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isUser() && props.onRevert}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
@@ -186,7 +200,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Show when={isUser() && props.onFork}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={() => props.onFork?.(props.message.id)}
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
@@ -213,72 +227,71 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayParts()}>
|
||||
<For each={combinedParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,38 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(candidate.content)) {
|
||||
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hasReasoningContent = () => {
|
||||
if (props.part?.type !== "reasoning") {
|
||||
return false
|
||||
}
|
||||
if (reasoningSegmentHasText((props.part as any).text)) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray((props.part as any).content)) {
|
||||
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createTextPartForMarkdown = (): TextPart => {
|
||||
const part = props.part
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
@@ -83,23 +115,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
|
||||
|
||||
<Match when={partType() === "reasoning"}>
|
||||
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
|
||||
<div class="message-reasoning">
|
||||
<div class="reasoning-container">
|
||||
<div class="reasoning-header" onClick={handleReasoningClick}>
|
||||
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
|
||||
<span class="reasoning-label">Reasoning</span>
|
||||
</div>
|
||||
<Show when={isReasoningExpanded()}>
|
||||
<div class={`${textContainerClass()} mt-2`}>
|
||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
1211
packages/ui/src/components/message-stream-v2.tsx
Normal file
1211
packages/ui/src/components/message-stream-v2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
instanceId: string
|
||||
@@ -132,9 +131,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
<span class="hint sidebar-selector-hint">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
|
||||
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
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: "Select OpenCode Binary",
|
||||
})
|
||||
if (selected) {
|
||||
setCustomPath(selected)
|
||||
void handleValidateAndAdd(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsBinaryBrowserOpen(true)
|
||||
}
|
||||
|
||||
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseBinary}
|
||||
onClick={() => void handleBrowseBinary()}
|
||||
disabled={props.disabled}
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import type { Agent } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
@@ -25,6 +24,7 @@ interface PromptInputProps {
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const HISTORY_LIMIT = 100
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [, setIsFocused] = createSignal(false)
|
||||
@@ -499,11 +499,27 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
const currentAttachments = attachments()
|
||||
if (props.disabled || !text) return
|
||||
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
||||
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()
|
||||
clearAttachments(props.instanceId, props.sessionId)
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
@@ -512,10 +528,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setHistoryDraft(null)
|
||||
|
||||
try {
|
||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
||||
const updated = await getHistory(props.instanceFolder)
|
||||
setHistory(updated)
|
||||
setHistoryIndex(-1)
|
||||
if (isShellMode) {
|
||||
if (props.onRunShell) {
|
||||
await props.onRunShell(resolvedPrompt)
|
||||
@@ -523,8 +535,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else {
|
||||
await props.onSend(text, currentAttachments)
|
||||
await props.onSend(resolvedPrompt, currentAttachments)
|
||||
}
|
||||
void refreshHistory()
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
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 shouldShowOverlay = () => prompt().length === 0
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
@@ -870,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
mode() === "shell"
|
||||
? "Run a shell command (Esc to exit)..."
|
||||
: "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={4}
|
||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-input-field">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
mode() === "shell"
|
||||
? "Run a shell command (Esc to exit)..."
|
||||
: "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={4}
|
||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||
spellcheck={false}
|
||||
autocorrect="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>
|
||||
|
||||
<button
|
||||
@@ -911,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ interface SessionListProps {
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 200
|
||||
const MAX_WIDTH = 500
|
||||
const DEFAULT_WIDTH = 280
|
||||
const STORAGE_KEY = "opencode-session-sidebar-width"
|
||||
const MAX_WIDTH = 520
|
||||
const DEFAULT_WIDTH = 360
|
||||
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
|
||||
@@ -7,54 +7,72 @@ interface ContextUsagePanelProps {
|
||||
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 info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = createMemo(() => info().tokens)
|
||||
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
|
||||
const contextWindow = createMemo(() => info().contextWindow)
|
||||
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
|
||||
|
||||
const costLabel = createMemo(() => {
|
||||
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
|
||||
return `$${info().cost.toFixed(2)} spent`
|
||||
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
||||
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
||||
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
||||
const costValue = createMemo(() => {
|
||||
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||
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 (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
|
||||
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Tokens</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Input</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||
</div>
|
||||
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<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 class={chipClass}>
|
||||
<span class={chipLabelClass}>Output</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||
</div>
|
||||
<div class="text-sm text-primary/90">
|
||||
{contextWindow()
|
||||
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
|
||||
: "Window size unavailable"}
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Cost</span>
|
||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
|
||||
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Context</div>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageStream from "../message-stream"
|
||||
import MessageStreamV2 from "../message-stream-v2"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
|
||||
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
|
||||
return part?.type === "text" && typeof (part as any).text === "string"
|
||||
}
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
@@ -19,6 +24,7 @@ interface SessionViewProps {
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
@@ -36,23 +42,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return null
|
||||
|
||||
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
|
||||
const targetInfo = currentSession.messagesInfo.get(messageId)
|
||||
if (!targetMessage || targetInfo?.role !== "user") {
|
||||
return null
|
||||
const normalizedMessage = messageStore().getMessage(messageId)
|
||||
if (normalizedMessage && normalizedMessage.role === "user") {
|
||||
const parts = normalizedMessage.partIds
|
||||
.map((partId) => normalizedMessage.parts[partId]?.data)
|
||||
.filter((part): part is ClientPart => Boolean(part))
|
||||
const textParts = parts.filter(isTextPart)
|
||||
if (textParts.length > 0) {
|
||||
return textParts.map((part) => part.text).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
|
||||
if (textParts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return textParts.map((p) => p.text).join("\n")
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
async function handleRevert(messageId: string) {
|
||||
const instance = instances().get(props.instanceId)
|
||||
if (!instance || !instance.client) return
|
||||
@@ -127,29 +131,30 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(s) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
revert={s().revert}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
{(sessionAccessor) => {
|
||||
const activeSession = sessionAccessor()
|
||||
if (!activeSession) return null
|
||||
return (
|
||||
<div class="session-view">
|
||||
<MessageStreamV2
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { isRenderableDiffText } from "../lib/diff-utils"
|
||||
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { sendPermissionResponse } from "../stores/instances"
|
||||
import type { TextPart, SDKPart, ClientPart } from "../types/message"
|
||||
import type { TextPart, SDKPart, ClientPart, RenderCache } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -33,46 +35,17 @@ function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
}
|
||||
|
||||
|
||||
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
messageId?: string,
|
||||
messageVersion?: number,
|
||||
partVersion?: number,
|
||||
partId?: string | null,
|
||||
variant = "default",
|
||||
) {
|
||||
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
|
||||
const keyBase = `${messageId}:${toolCallId}`
|
||||
return `${keyBase}::${suffix}`
|
||||
}
|
||||
|
||||
function updateScrollState(id: string, element: HTMLElement) {
|
||||
if (!id) return
|
||||
const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight)
|
||||
const atBottom = distanceFromBottom <= 2
|
||||
toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom })
|
||||
}
|
||||
|
||||
function restoreScrollState(id: string, element: HTMLElement) {
|
||||
if (!id) return
|
||||
const state = toolScrollState.get(id)
|
||||
if (!state) {
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
updateScrollState(id, element)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (state.atBottom) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
} else {
|
||||
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
element.scrollTop = Math.min(state.scrollTop, maxScrollTop)
|
||||
}
|
||||
updateScrollState(id, element)
|
||||
})
|
||||
const messageComponent = messageId ?? "unknown-message"
|
||||
const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
|
||||
return `${messageComponent}:${toolCallComponent}:${variant}`
|
||||
}
|
||||
|
||||
|
||||
@@ -346,7 +319,36 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
const cacheContext = createMemo(() => ({
|
||||
toolCallId: toolCallId(),
|
||||
messageId: props.messageId,
|
||||
partId: props.toolCall?.id ?? null,
|
||||
}))
|
||||
|
||||
const createVariantCache = (variant: string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: TOOL_CALL_CACHE_SCOPE,
|
||||
key: () => {
|
||||
const context = cacheContext()
|
||||
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
|
||||
},
|
||||
})
|
||||
|
||||
const diffCache = createVariantCache("diff")
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const markdownCache = createVariantCache("markdown")
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, props.toolCall?.id))
|
||||
const pendingPermission = createMemo(() => {
|
||||
const state = permissionState()
|
||||
if (state) {
|
||||
return { permission: state.entry.permission, active: state.active }
|
||||
}
|
||||
return props.toolCall.pendingPermission
|
||||
})
|
||||
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
|
||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
@@ -374,30 +376,49 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let toolCallRootRef: HTMLDivElement | undefined
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
|
||||
const id = toolCallId()
|
||||
|
||||
if (!id || !scrollContainerRef) return
|
||||
restoreScrollState(id, scrollContainerRef)
|
||||
const scrollScopeId = createMemo(() => {
|
||||
const id = toolCallId()
|
||||
if (id) return id
|
||||
const messageKey = props.messageId || "unknown"
|
||||
const partKey = typeof props.partVersion === "number" ? props.partVersion : 0
|
||||
return `${messageKey}:${partKey}`
|
||||
})
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: () => `${TOOL_CALL_CACHE_SCOPE}:scroll:${scrollScopeId()}`,
|
||||
})
|
||||
|
||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
scrollCache.persist(element, { atBottomOffset: 2 })
|
||||
}
|
||||
|
||||
const restoreScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
scrollCache.restore(element, {
|
||||
fallback: () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!element || !element.isConnected) return
|
||||
element.scrollTop = element.scrollHeight
|
||||
persistScrollSnapshot(element)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
if (!scrollContainerRef) return
|
||||
restoreScrollSnapshot(scrollContainerRef)
|
||||
}
|
||||
|
||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||
const resolvedElement = element || undefined
|
||||
scrollContainerRef = resolvedElement
|
||||
const id = toolCallId()
|
||||
if (!resolvedElement || !id) return
|
||||
|
||||
if (!toolScrollState.has(id)) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef || toolCallId() !== id) return
|
||||
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
|
||||
updateScrollState(id, scrollContainerRef)
|
||||
})
|
||||
} else {
|
||||
restoreScrollState(id, resolvedElement)
|
||||
}
|
||||
if (!resolvedElement) return
|
||||
restoreScrollSnapshot(resolvedElement)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -426,16 +447,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup cache entry when component unmounts or toolCallId changes
|
||||
createEffect(() => {
|
||||
const id = toolCallId()
|
||||
if (!id) return
|
||||
|
||||
onCleanup(() => {
|
||||
toolScrollState.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.toolCall?.tool !== "task") return
|
||||
const state = props.toolCall?.state
|
||||
@@ -725,25 +736,20 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderMarkdownTool(toolName, state)
|
||||
}
|
||||
|
||||
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||
function renderDiffTool(payload: DiffPayload, options?: { variant?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
|
||||
// Check if we have valid cache
|
||||
let cachedHtml: string | undefined
|
||||
if (cacheKey) {
|
||||
const cached = getToolRenderCache(cacheKey)
|
||||
const currentMode = diffMode()
|
||||
if (cached &&
|
||||
cached.text === payload.diffText &&
|
||||
cached.theme === themeKey &&
|
||||
cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
const cached = cacheHandle.get<RenderCache>()
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
@@ -751,10 +757,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (cacheKey && !cachedHtml) {
|
||||
// Cache will be updated by the diff viewer component itself
|
||||
// We'll capture HTML from the rendered component
|
||||
}
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
@@ -767,7 +769,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
if (options?.disableScrollTracking) return
|
||||
initializeScrollContainer(element)
|
||||
}}
|
||||
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
@@ -797,7 +799,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheKey={cacheKey}
|
||||
cacheEntryParams={cacheHandle.params()}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
</div>
|
||||
@@ -813,20 +815,15 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
|
||||
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
|
||||
const disableHighlight = state?.status === "running"
|
||||
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
|
||||
const markdownPart: TextPart = { type: "text", text: content }
|
||||
if (cacheKey) {
|
||||
const cached = getToolRenderCache(cacheKey)
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
const cached = markdownCache.get<RenderCache>()
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
if (cacheKey) {
|
||||
setToolRenderCache(cacheKey, markdownPart.renderCache)
|
||||
}
|
||||
markdownCache.set(markdownPart.renderCache)
|
||||
handleScrollRendered()
|
||||
}
|
||||
|
||||
@@ -834,7 +831,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
@@ -1044,7 +1041,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={summary}>
|
||||
@@ -1122,7 +1119,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffTool(payload(), {
|
||||
cacheKeySuffix: "::permission",
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
126
packages/ui/src/lib/global-cache.ts
Normal file
126
packages/ui/src/lib/global-cache.ts
Normal 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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
||||
import { createCommandRegistry, type Command } from "../commands"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
@@ -13,13 +14,17 @@ import {
|
||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleUsageMetrics: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
@@ -28,6 +33,18 @@ export interface UseCommandsOptions {
|
||||
getActiveSessionIdForInstance: () => string | null
|
||||
}
|
||||
|
||||
function extractUserTextFromRecord(record?: MessageRecord): string | null {
|
||||
if (!record) return null
|
||||
const parts = record.partIds
|
||||
.map((partId) => record.parts[partId]?.data)
|
||||
.filter((part): part is ClientPart => Boolean(part))
|
||||
const textParts = parts.filter((part): part is ClientPart & { type: "text"; text: string } => part.type === "text" && typeof (part as any).text === "string")
|
||||
if (textParts.length === 0) {
|
||||
return null
|
||||
}
|
||||
return textParts.map((part) => (part as any).text as string).join("\n")
|
||||
}
|
||||
|
||||
export function useCommands(options: UseCommandsOptions) {
|
||||
const commandRegistry = createCommandRegistry()
|
||||
const [commands, setCommands] = createSignal<Command[]>([])
|
||||
@@ -231,30 +248,33 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
let after = 0
|
||||
const revert = session.revert
|
||||
const store = messageStoreBus.getOrCreate(instance.id)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
const infoMap = new Map<string, MessageInfo>()
|
||||
messageIds.forEach((id) => {
|
||||
const info = store.getMessageInfo(id)
|
||||
if (info) infoMap.set(id, info)
|
||||
})
|
||||
|
||||
if (revert?.messageID) {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
if (info?.id === revert.messageID) {
|
||||
after = info.time?.created || 0
|
||||
break
|
||||
}
|
||||
}
|
||||
const revertState = store.getSessionRevert(sessionId) ?? session.revert
|
||||
let after = 0
|
||||
if (revertState?.messageID) {
|
||||
const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID)
|
||||
after = revertInfo?.time?.created || 0
|
||||
}
|
||||
|
||||
let messageID = ""
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
|
||||
if (msg.type === "user" && info?.time?.created) {
|
||||
let restoredText: string | null = null
|
||||
for (let i = messageIds.length - 1; i >= 0; i--) {
|
||||
const id = messageIds[i]
|
||||
const record = store.getMessage(id)
|
||||
const info = infoMap.get(id) ?? store.getMessageInfo(id)
|
||||
if (record?.role === "user" && info?.time?.created) {
|
||||
if (after > 0 && info.time.created >= after) {
|
||||
continue
|
||||
}
|
||||
messageID = msg.id
|
||||
messageID = id
|
||||
restoredText = extractUserTextFromRecord(record)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -273,18 +293,17 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
body: { messageID },
|
||||
})
|
||||
|
||||
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
||||
const revertedInfo = session.messagesInfo.get(messageID)
|
||||
if (!restoredText) {
|
||||
const fallbackRecord = store.getMessage(messageID)
|
||||
restoredText = extractUserTextFromRecord(fallbackRecord)
|
||||
}
|
||||
|
||||
if (revertedMessage && revertedInfo?.role === "user") {
|
||||
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
|
||||
if (textParts.length > 0) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = textParts.map((p: any) => p.text).join("\n")
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -367,10 +386,26 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
|
||||
description: "Show/hide AI thinking process",
|
||||
category: "System",
|
||||
keywords: ["/thinking", "toggle", "show", "hide"],
|
||||
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
|
||||
action: options.toggleShowThinkingBlocks,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||
return `Thinking Blocks Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
},
|
||||
description: "Toggle whether thinking blocks start expanded",
|
||||
category: "System",
|
||||
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
|
||||
action: () => {
|
||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
options.setThinkingBlocksExpansion(next)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diff-view-split",
|
||||
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
|
||||
@@ -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({
|
||||
id: "help",
|
||||
label: "Show Help",
|
||||
|
||||
description: "Display keyboard shortcuts and help",
|
||||
category: "System",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
|
||||
86
packages/ui/src/lib/hooks/use-global-cache.ts
Normal file
86
packages/ui/src/lib/hooks/use-global-cache.ts
Normal 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
|
||||
}
|
||||
102
packages/ui/src/lib/hooks/use-scroll-cache.ts
Normal file
102
packages/ui/src/lib/hooks/use-scroll-cache.ts
Normal 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>
|
||||
39
packages/ui/src/lib/native/electron/functions.ts
Normal file
39
packages/ui/src/lib/native/electron/functions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
37
packages/ui/src/lib/native/native-functions.ts
Normal file
37
packages/ui/src/lib/native/native-functions.ts
Normal 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 ?? {}) })
|
||||
}
|
||||
55
packages/ui/src/lib/native/tauri/functions.ts
Normal file
55
packages/ui/src/lib/native/tauri/functions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
13
packages/ui/src/lib/native/types.ts
Normal file
13
packages/ui/src/lib/native/types.ts
Normal 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[]
|
||||
}
|
||||
86
packages/ui/src/lib/runtime-env.ts
Normal file
86
packages/ui/src/lib/runtime-env.ts
Normal 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"
|
||||
@@ -14,16 +14,15 @@ import type {
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} 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 {
|
||||
instanceId: string
|
||||
proxyPath: string
|
||||
eventSource: EventSource
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
|
||||
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
@@ -35,7 +34,7 @@ interface TuiToastEvent {
|
||||
}
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
type SSEEvent =
|
||||
| MessageUpdateEvent
|
||||
| MessageRemovedEvent
|
||||
| MessagePartUpdatedEvent
|
||||
@@ -48,73 +47,43 @@ type SSEEvent =
|
||||
| EventPermissionReplied
|
||||
| EventLspUpdated
|
||||
| TuiToastEvent
|
||||
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
Map<string, "connecting" | "connected" | "disconnected" | "error">
|
||||
>(new Map())
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private static readonly MAX_RECONNECT_DELAY_MS = 5000
|
||||
|
||||
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
|
||||
const existing = this.connections.get(instanceId)
|
||||
if (existing) {
|
||||
this.clearReconnectTimer(existing)
|
||||
existing.eventSource.close()
|
||||
}
|
||||
|
||||
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)
|
||||
constructor() {
|
||||
serverEvents.on("instance.eventStatus", (event) => {
|
||||
const payload = event as InstanceStatusPayload
|
||||
this.updateConnectionStatus(payload.instanceId, payload.status)
|
||||
if (payload.status === "disconnected") {
|
||||
if (payload.reason === "workspace stopped") {
|
||||
return
|
||||
}
|
||||
const reason = payload.reason ?? "Instance disconnected"
|
||||
void this.onConnectionLost?.(payload.instanceId, reason)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connection.status = "error"
|
||||
this.updateConnectionStatus(instanceId, "error")
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleConnectionError(instanceId, "Connection to instance lost")
|
||||
}
|
||||
serverEvents.on("instance.event", (event) => {
|
||||
const payload = event as InstanceEventPayload
|
||||
this.updateConnectionStatus(payload.instanceId, "connected")
|
||||
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
||||
}
|
||||
seedStatus(instanceId: string, status: ConnectionStatus) {
|
||||
this.updateConnectionStatus(instanceId, status)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
switch (event.type) {
|
||||
@@ -159,35 +128,7 @@ class SSEManager {
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectionError(instanceId: string, reason: string): 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 {
|
||||
private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
|
||||
setConnectionStatus((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, status)
|
||||
@@ -209,7 +150,7 @@ class SSEManager {
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => 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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -21,14 +21,11 @@ function applyTheme(dark: boolean) {
|
||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(false)
|
||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||
|
||||
const resolveDarkTheme = () => {
|
||||
const preference = themePreference()
|
||||
if (preference === "system") {
|
||||
return systemPrefersDark.matches
|
||||
}
|
||||
return preference === "dark"
|
||||
themePreference()
|
||||
return true
|
||||
}
|
||||
|
||||
const applyResolvedTheme = () => {
|
||||
@@ -42,11 +39,8 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
||||
if (themePreference() === "system") {
|
||||
setIsDarkSignal(event.matches)
|
||||
applyTheme(event.matches)
|
||||
}
|
||||
const handleSystemThemeChange = () => {
|
||||
applyResolvedTheme()
|
||||
}
|
||||
|
||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
||||
@@ -56,12 +50,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
}
|
||||
})
|
||||
|
||||
const setTheme = (dark: boolean) => {
|
||||
setThemePreference(dark ? "dark" : "light")
|
||||
const setTheme = (_dark: boolean) => {
|
||||
setThemePreference("dark")
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(!isDark())
|
||||
setTheme(true)
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import App from "./App"
|
||||
import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -12,6 +13,11 @@ if (!root) {
|
||||
throw new Error("Root element not found")
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.runtimeHost = runtimeEnv.host
|
||||
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
|
||||
@@ -6,30 +6,18 @@
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
color-scheme: dark;
|
||||
}
|
||||
/* html,
|
||||
html,
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { */
|
||||
html,
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* } */
|
||||
</style>
|
||||
<script>
|
||||
;(function () {
|
||||
try {
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
// if (prefersDark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
// } else {
|
||||
// document.documentElement.removeAttribute('data-theme')
|
||||
// }
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply initial theme', error)
|
||||
}
|
||||
|
||||
21
packages/ui/src/renderer/loading.html
Normal file
21
packages/ui/src/renderer/loading.html
Normal 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>
|
||||
111
packages/ui/src/renderer/loading/loading.css
Normal file
111
packages/ui/src/renderer/loading/loading.css
Normal 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);
|
||||
}
|
||||
}
|
||||
166
packages/ui/src/renderer/loading/main.tsx
Normal file
166
packages/ui/src/renderer/loading/main.tsx
Normal 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 AI’s 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)
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
@@ -12,14 +13,15 @@ import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
removeSessionIndexes,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { computeDisplayParts } from "./session-messages"
|
||||
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||
import { setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
@@ -52,7 +54,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
error: descriptor.error,
|
||||
client: existing?.client ?? null,
|
||||
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 ?? {},
|
||||
}
|
||||
}
|
||||
@@ -87,7 +91,6 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(descriptor.id)
|
||||
sseManager.disconnect(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
@@ -97,7 +100,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
proxyPath: nextProxyPath,
|
||||
status: "ready",
|
||||
})
|
||||
sseManager.connect(descriptor.id, nextProxyPath)
|
||||
sseManager.seedStatus(descriptor.id, "connecting")
|
||||
void hydrateInstanceData(descriptor.id).catch((error) => {
|
||||
console.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
@@ -110,7 +113,7 @@ function releaseInstanceResources(instanceId: string) {
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
}
|
||||
sseManager.disconnect(instanceId)
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
@@ -293,7 +296,8 @@ function removeInstance(id: string) {
|
||||
}
|
||||
|
||||
// Clean up session indexes and drafts for removed instance
|
||||
removeSessionIndexes(id)
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
}
|
||||
|
||||
@@ -538,23 +542,49 @@ function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
|
||||
const expectedCallId = permission.callID
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID
|
||||
function getPermissionMessageId(permission: Permission): string | undefined {
|
||||
return (permission as any).messageID ?? (permission as any).messageId ?? undefined
|
||||
}
|
||||
|
||||
function getPermissionCallIdentifier(permission: Permission): string | undefined {
|
||||
return (
|
||||
(permission as any).callID ??
|
||||
(permission as any).callId ??
|
||||
(permission as any).toolCallID ??
|
||||
(permission as any).toolCallId ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
function findToolPartForPermission(record: MessageRecord, permission: Permission): { partId: string; part: ClientPart } | null {
|
||||
const expectedCallId = getPermissionCallIdentifier(permission)
|
||||
const permissionId = permission.id
|
||||
const permissionMessageId = getPermissionMessageId(permission)
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts[partId]
|
||||
if (!entry) continue
|
||||
const part = entry.data
|
||||
if (!part || part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID ?? (part as any).callId
|
||||
const partMessageId = (part as any).messageID ?? (part as any).messageId
|
||||
|
||||
if (expectedCallId) {
|
||||
if (toolCallId === expectedCallId) {
|
||||
return part as ClientPart
|
||||
return { partId, part }
|
||||
}
|
||||
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
|
||||
return part as ClientPart
|
||||
if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) {
|
||||
return { partId, part }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
|
||||
return part as ClientPart
|
||||
if (
|
||||
(toolCallId && toolCallId === permissionId) ||
|
||||
part.id === permissionId ||
|
||||
(permissionMessageId && partMessageId === permissionMessageId)
|
||||
) {
|
||||
return { partId, part }
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -563,23 +593,31 @@ function findToolPartForPermission(message: Message, permission: Permission): Cl
|
||||
function mutateToolPartPermission(
|
||||
instanceId: string,
|
||||
permission: Permission,
|
||||
mutator: (part: ClientPart, message: Message) => boolean,
|
||||
mutator: (part: ClientPart) => boolean,
|
||||
): void {
|
||||
const permissionSessionId = getPermissionSessionId(permission)
|
||||
withSession(instanceId, permissionSessionId, (session) => {
|
||||
const message = session.messages.find((msg) => msg.id === permission.messageID)
|
||||
if (!message) return
|
||||
const targetPart = findToolPartForPermission(message, permission)
|
||||
if (!targetPart) return
|
||||
const messageId = getPermissionMessageId(permission)
|
||||
if (!messageId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageRecord = store.getMessage(messageId)
|
||||
if (!messageRecord) return
|
||||
const targetPart = findToolPartForPermission(messageRecord, permission)
|
||||
if (!targetPart) return
|
||||
|
||||
const changed = mutator(targetPart, message)
|
||||
if (!changed) return
|
||||
|
||||
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
|
||||
targetPart.version = nextPartVersion
|
||||
message.version = (message.version ?? 0) + 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
})
|
||||
store.setState(
|
||||
"messages",
|
||||
messageId,
|
||||
produce((draft: MessageRecord) => {
|
||||
const partRecord = draft.parts[targetPart.partId]
|
||||
if (!partRecord) return
|
||||
const changed = mutator(partRecord.data)
|
||||
if (!changed) return
|
||||
const nextVersion = typeof partRecord.data.version === "number" ? partRecord.data.version + 1 : 1
|
||||
partRecord.data.version = nextVersion
|
||||
partRecord.revision += 1
|
||||
draft.revision += 1
|
||||
draft.updatedAt = Date.now()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||
|
||||
166
packages/ui/src/stores/message-v2/bridge.ts
Normal file
166
packages/ui/src/stores/message-v2/bridge.ts
Normal 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)
|
||||
}
|
||||
64
packages/ui/src/stores/message-v2/bus.ts
Normal file
64
packages/ui/src/stores/message-v2/bus.ts
Normal 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()
|
||||
768
packages/ui/src/stores/message-v2/instance-store.ts
Normal file
768
packages/ui/src/stores/message-v2/instance-store.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
74
packages/ui/src/stores/message-v2/normalizers.ts
Normal file
74
packages/ui/src/stores/message-v2/normalizers.ts
Normal 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
|
||||
}
|
||||
|
||||
46
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal file
46
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
packages/ui/src/stores/message-v2/session-info.ts
Normal file
139
packages/ui/src/stores/message-v2/session-info.ts
Normal 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
|
||||
})
|
||||
}
|
||||
139
packages/ui/src/stores/message-v2/types.ts
Normal file
139
packages/ui/src/stores/message-v2/types.ts
Normal 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
|
||||
}
|
||||
@@ -29,12 +29,14 @@ export type ExpansionPreference = "expanded" | "collapsed"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
lastUsedBinary?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
showUsageMetrics: boolean
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
@@ -55,11 +57,13 @@ const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
showUsageMetrics: true,
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
@@ -86,12 +90,14 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
|
||||
return {
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +272,19 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void {
|
||||
updatePreferences({ diagnosticsExpansion: mode })
|
||||
}
|
||||
|
||||
function setThinkingBlocksExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().thinkingBlocksExpansion === mode) return
|
||||
updatePreferences({ thinkingBlocksExpansion: mode })
|
||||
}
|
||||
|
||||
function toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
|
||||
function toggleUsageMetrics(): void {
|
||||
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
updateConfig((draft) => {
|
||||
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
||||
@@ -370,9 +385,11 @@ interface ConfigContextValue {
|
||||
setThemePreference: typeof setThemePreference
|
||||
updateConfig: typeof updateConfig
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
@@ -400,9 +417,11 @@ const configContextValue: ConfigContextValue = {
|
||||
setThemePreference,
|
||||
updateConfig,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
@@ -454,6 +473,7 @@ export {
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleUsageMetrics,
|
||||
recentFolders,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
@@ -470,7 +490,9 @@ export {
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
recordWorkspaceLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import {
|
||||
addRecentModelPreference,
|
||||
preferences,
|
||||
setAgentModelPreference,
|
||||
} from "./preferences"
|
||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
@@ -93,26 +83,6 @@ async function sendMessage(
|
||||
},
|
||||
]
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: "user",
|
||||
parts: optimisticParts,
|
||||
timestamp: Date.now(),
|
||||
status: "sending",
|
||||
version: 0,
|
||||
}
|
||||
|
||||
optimisticParts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.messages.push(optimisticMessage)
|
||||
const index = getSessionIndex(instanceId, sessionId)
|
||||
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
|
||||
})
|
||||
|
||||
const requestParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
@@ -167,6 +137,24 @@ async function sendMessage(
|
||||
}
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const createdAt = Date.now()
|
||||
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role: "user",
|
||||
status: "sending",
|
||||
parts: optimisticParts,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isEphemeral: true,
|
||||
})
|
||||
|
||||
withSession(instanceId, sessionId, () => {
|
||||
/* trigger reactivity for legacy session data */
|
||||
})
|
||||
|
||||
const requestBody = {
|
||||
messageID: messageId,
|
||||
parts: requestParts,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances, refreshPermissionsForSession } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
@@ -21,16 +21,12 @@ import {
|
||||
loading,
|
||||
setLoading,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
clearSessionIndex,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
|
||||
interface SessionForkResponse {
|
||||
id: string
|
||||
@@ -99,8 +95,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: 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,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
}
|
||||
|
||||
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 initialContextWindow = initialModel?.limit?.context ?? 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) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(session.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: initialContextWindow,
|
||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: initialContextPercent,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: initialOutputLimit,
|
||||
contextAvailableTokens: initialContextAvailable,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, session.id)
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
@@ -293,8 +290,6 @@ async function forkSession(
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as unknown as Session
|
||||
|
||||
setSessions((prev) => {
|
||||
@@ -310,25 +305,28 @@ async function forkSession(
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
const forkContextWindow = forkModel?.limit?.context ?? 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) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(forkedSession.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: forkContextWindow,
|
||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: forkContextPercent,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: forkOutputLimit,
|
||||
contextAvailableTokens: forkContextAvailable,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, forkedSession.id)
|
||||
|
||||
return forkedSession
|
||||
}
|
||||
|
||||
@@ -362,6 +360,10 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
setSessionCompactionState(instanceId, sessionId, false)
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
// Drop normalized message state and caches for this session
|
||||
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
|
||||
clearCacheForSession(instanceId, sessionId)
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = next.get(instanceId)
|
||||
@@ -377,8 +379,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
return next
|
||||
})
|
||||
|
||||
clearSessionIndex(instanceId, sessionId)
|
||||
|
||||
if (activeSessionId().get(instanceId) === sessionId) {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -508,8 +508,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session.messages({ path: { id: sessionId } })
|
||||
console.log(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
@@ -535,10 +535,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
version: 0,
|
||||
}
|
||||
|
||||
parts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
@@ -573,8 +569,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
messages,
|
||||
messagesInfo,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
@@ -586,8 +580,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
return next
|
||||
})
|
||||
|
||||
rebuildSessionIndex(instanceId, sessionId, messages)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId) || new Set()
|
||||
@@ -595,6 +587,15 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
next.set(instanceId, loadedSet)
|
||||
return next
|
||||
})
|
||||
|
||||
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
|
||||
id: sessionId,
|
||||
title: session?.title,
|
||||
parentId: session?.parentId ?? null,
|
||||
revert: session?.revert,
|
||||
}
|
||||
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
throw error
|
||||
@@ -608,17 +609,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MessageInfo,
|
||||
MessagePartRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessageRemovedEvent,
|
||||
@@ -12,9 +13,9 @@ import type {
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import {
|
||||
@@ -22,17 +23,20 @@ import {
|
||||
setSessions,
|
||||
withSession,
|
||||
} from "./session-state"
|
||||
import {
|
||||
bumpPartVersion,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
upsertMessageInfoV2,
|
||||
upsertPermissionV2,
|
||||
removePermissionV2,
|
||||
setSessionRevertV2,
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
@@ -46,6 +50,30 @@ interface TuiToastEvent {
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
type MessageRole = "user" | "assistant"
|
||||
|
||||
function resolveMessageRole(info?: MessageInfo | null): MessageRole {
|
||||
return info?.role === "user" ? "user" : "assistant"
|
||||
}
|
||||
|
||||
function findPendingMessageId(
|
||||
store: InstanceMessageStore,
|
||||
sessionId: string,
|
||||
role: MessageRole,
|
||||
): string | undefined {
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
for (let i = messageIds.length - 1; i >= 0; i -= 1) {
|
||||
const record = store.getMessage(messageIds[i])
|
||||
if (!record) continue
|
||||
if (record.sessionId !== sessionId) continue
|
||||
if (record.role !== role) continue
|
||||
if (record.status === "sending") {
|
||||
return record.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
@@ -55,264 +83,92 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
if (!rawPart) return
|
||||
|
||||
const part = normalizeMessagePart(rawPart)
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : undefined
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(part.sessionID)
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, part.sessionID)
|
||||
let messageIndex = index.messageIndex.get(part.messageID)
|
||||
let replacedTemp = false
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
|
||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === part.sessionID && msg.status === "sending") {
|
||||
messageIndex = i
|
||||
replacedTemp = true
|
||||
break
|
||||
}
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
const newMessage: any = {
|
||||
id: part.messageID,
|
||||
sessionId: part.sessionID,
|
||||
type: "assistant" as const,
|
||||
parts: [part],
|
||||
timestamp: Date.now(),
|
||||
status: "streaming" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
initializePartVersion(part)
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
let filteredSynthetics = false
|
||||
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
||||
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
||||
filteredSynthetics = true
|
||||
message.parts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let baseParts: any[]
|
||||
if (replacedTemp) {
|
||||
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
|
||||
message.parts = baseParts
|
||||
baseParts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
baseParts = message.parts
|
||||
}
|
||||
|
||||
let partMap = index.partIndex.get(message.id)
|
||||
if (!partMap) {
|
||||
partMap = new Map()
|
||||
index.partIndex.set(message.id, partMap)
|
||||
}
|
||||
|
||||
let shouldIncrementVersion = filteredSynthetics || replacedTemp
|
||||
const partIndex = partMap.get(part.id)
|
||||
|
||||
if (partIndex === undefined) {
|
||||
initializePartVersion(part)
|
||||
baseParts.push(part)
|
||||
if (part.id && typeof part.id === "string") {
|
||||
partMap.set(part.id, baseParts.length - 1)
|
||||
}
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
} else {
|
||||
const previousPart = baseParts[partIndex]
|
||||
const textUnchanged =
|
||||
!filteredSynthetics &&
|
||||
!replacedTemp &&
|
||||
part.type === "text" &&
|
||||
previousPart?.type === "text" &&
|
||||
previousPart.text === part.text
|
||||
|
||||
if (textUnchanged) {
|
||||
return
|
||||
}
|
||||
|
||||
bumpPartVersion(previousPart, part)
|
||||
baseParts[partIndex] = part
|
||||
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = replacedTemp ? part.messageID : message.id
|
||||
message.status = message.status === "sending" ? "streaming" : message.status
|
||||
message.parts = baseParts
|
||||
|
||||
if (shouldIncrementVersion) {
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
} else if (
|
||||
!message.displayParts ||
|
||||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
|
||||
message.displayParts.version !== message.version
|
||||
) {
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, messageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSynthetics || replacedTemp) {
|
||||
const refreshed = new Map<string, number>()
|
||||
message.parts.forEach((partItem, idx) => {
|
||||
if (partItem.id && typeof partItem.id === "string") {
|
||||
refreshed.set(partItem.id, idx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, refreshed)
|
||||
}
|
||||
if (!record) {
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role,
|
||||
status: "streaming",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isEphemeral: true,
|
||||
})
|
||||
}
|
||||
|
||||
withSession(instanceId, part.sessionID, () => {
|
||||
/* mutations already applied above */
|
||||
})
|
||||
if (messageInfo) {
|
||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, part.sessionID)
|
||||
refreshPermissionsForSession(instanceId, part.sessionID)
|
||||
applyPartUpdateV2(instanceId, part)
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const session = instanceSessions.get(info.sessionID)
|
||||
const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined
|
||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, info.sessionID)
|
||||
let messageIndex = index.messageIndex.get(info.id)
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||
const hasError = Boolean((info as any).error)
|
||||
const status: MessageStatus = hasError ? "error" : "complete"
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
let tempMessageIndex = -1
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (
|
||||
msg.sessionId === info.sessionID &&
|
||||
msg.type === (info.role === "user" ? "user" : "assistant") &&
|
||||
msg.status === "sending"
|
||||
) {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
|
||||
if (tempMessageIndex === -1) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === info.sessionID && msg.status === "sending") {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tempMessageIndex > -1) {
|
||||
const message = session.messages[tempMessageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = info.id
|
||||
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
|
||||
message.timestamp = info.time?.created || Date.now()
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, tempMessageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newMessage: any = {
|
||||
id: info.id,
|
||||
sessionId: info.sessionID,
|
||||
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
|
||||
parts: [],
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
|
||||
}
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
session.messagesInfo.set(info.id, info)
|
||||
withSession(instanceId, info.sessionID, () => {
|
||||
/* ensure reactivity */
|
||||
})
|
||||
if (!record) {
|
||||
const createdAt = info.time?.created ?? Date.now()
|
||||
const completedAt = (info.time as { completed?: number } | undefined)?.completed
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role,
|
||||
status,
|
||||
createdAt,
|
||||
updatedAt: completedAt ?? createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, info.sessionID)
|
||||
refreshPermissionsForSession(instanceId, info.sessionID)
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||
const info = event.properties?.info
|
||||
@@ -345,8 +201,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as any
|
||||
|
||||
setSessions((prev) => {
|
||||
@@ -356,6 +210,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
|
||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
@@ -388,6 +243,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
next.set(instanceId, updated)
|
||||
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})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
@@ -495,6 +352,7 @@ function handlePermissionReplied(instanceId: string, event: EventPermissionRepli
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
removePermissionV2(instanceId, permissionID)
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
|
||||
export interface SessionInfo {
|
||||
tokens: number
|
||||
cost: number
|
||||
contextWindow: number
|
||||
isSubscriptionModel: boolean
|
||||
contextUsageTokens: number
|
||||
contextUsagePercent: number | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
actualUsageTokens: number
|
||||
modelOutputLimit: number
|
||||
contextAvailableTokens: number | null
|
||||
}
|
||||
|
||||
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 = {
|
||||
...session,
|
||||
messages: [...session.messages],
|
||||
messagesInfo: new Map(session.messagesInfo),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { Message, MessageInfo } from "../types/message"
|
||||
import type { MessageInfo } from "../types/message"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
@@ -17,36 +19,13 @@ function isSessionCompacting(session: Session): boolean {
|
||||
return Boolean(compactingFlag)
|
||||
}
|
||||
|
||||
function getMessageTimestamp(session: Session, message?: Message): number {
|
||||
if (!message) return Number.NEGATIVE_INFINITY
|
||||
if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
|
||||
return message.timestamp
|
||||
}
|
||||
const info = session.messagesInfo.get(message.id)
|
||||
return info?.time?.created ?? Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getLastMessage(session: Session): Message | undefined {
|
||||
let latest: Message | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const message of session.messages) {
|
||||
if (!message) continue
|
||||
const timestamp = getMessageTimestamp(session, message)
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = message
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
if (session.messagesInfo.size === 0) {
|
||||
return undefined
|
||||
}
|
||||
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageInfo | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const info of session.messagesInfo.values()) {
|
||||
for (const id of messageIds) {
|
||||
const info = store.getMessageInfo(id)
|
||||
if (!info) continue
|
||||
if (role && info.role !== role) continue
|
||||
const timestamp = info.time?.created ?? 0
|
||||
@@ -58,6 +37,25 @@ function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): Messa
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageRecord | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const id of messageIds) {
|
||||
const record = store.getMessage(id)
|
||||
if (!record) continue
|
||||
const info = store.getMessageInfo(id)
|
||||
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = record
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
|
||||
function getInfoCreatedTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
@@ -92,16 +90,16 @@ function isAssistantInfoPending(info?: MessageInfo): boolean {
|
||||
return completed < created
|
||||
}
|
||||
|
||||
function isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean {
|
||||
if (message.type !== "assistant") {
|
||||
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
|
||||
if (record.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "error") {
|
||||
if (record.status === "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "streaming" || message.status === "sending") {
|
||||
if (record.status === "streaming" || record.status === "sending") {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -110,24 +108,29 @@ function isAssistantStillGenerating(message: Message, info?: MessageInfo): boole
|
||||
return false
|
||||
}
|
||||
|
||||
return !(message.status === "complete" || message.status === "sent")
|
||||
return !(record.status === "complete" || record.status === "sent")
|
||||
}
|
||||
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
return "idle"
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
const latestUserInfo = getLastMessageInfo(session, "user")
|
||||
const latestAssistantInfo = getLastMessageInfo(session, "assistant")
|
||||
const lastMessage = getLastMessage(session)
|
||||
if (!lastMessage) {
|
||||
const latestInfo = getLastMessageInfo(session)
|
||||
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user")
|
||||
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant")
|
||||
|
||||
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
|
||||
|
||||
if (!lastRecord) {
|
||||
const latestInfo = latestUserInfo ?? latestAssistantInfo
|
||||
if (!latestInfo) {
|
||||
return "idle"
|
||||
}
|
||||
@@ -138,12 +141,11 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
||||
return infoCompleted ? "idle" : "working"
|
||||
}
|
||||
|
||||
if (lastMessage.type === "user") {
|
||||
if (lastRecord.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo
|
||||
if (isAssistantStillGenerating(lastMessage, infoForMessage)) {
|
||||
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
|
||||
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
setSessionDraftPrompt,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel } from "./session-models"
|
||||
import { computeDisplayParts, removeSessionIndexes } from "./session-messages"
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
@@ -79,7 +78,6 @@ export {
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
computeDisplayParts,
|
||||
createSession,
|
||||
deleteSession,
|
||||
executeCustomCommand,
|
||||
@@ -102,7 +100,6 @@ export {
|
||||
loadMessages,
|
||||
loading,
|
||||
providers,
|
||||
removeSessionIndexes,
|
||||
sendMessage,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
|
||||
@@ -6,8 +6,42 @@
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-item-base:not(.assistant-message) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-step-start {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-step-finish {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-step-finish-flush {
|
||||
margin-top: -0.125rem;
|
||||
}
|
||||
|
||||
.message-step-usage {
|
||||
@apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.message-step-heading {
|
||||
@apply flex flex-wrap items-center gap-2 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.message-queued-badge {
|
||||
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
|
||||
background-color: var(--accent-primary);
|
||||
@@ -101,3 +135,171 @@
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.message-step-card {
|
||||
@apply flex flex-col gap-2 px-3 py-2;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.message-step-start {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-step-finish {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-step-heading {
|
||||
@apply flex flex-wrap items-center gap-2 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-step-title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
@apply flex items-center justify-between w-full;
|
||||
}
|
||||
|
||||
.message-step-title-left {
|
||||
@apply flex items-center gap-2 text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.message-step-title-left span:last-child {
|
||||
@apply font-medium text-[11px];
|
||||
}
|
||||
|
||||
.message-step-time {
|
||||
@apply text-[11px] text-[var(--text-muted)] font-normal ml-auto;
|
||||
}
|
||||
|
||||
.message-step-meta-inline {
|
||||
@apply inline-flex flex-wrap items-center gap-2 text-[11px] font-medium;
|
||||
color: var(--message-assistant-border);
|
||||
}
|
||||
|
||||
|
||||
.message-step-reason {
|
||||
@apply text-[11px] font-medium;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-step-finish-spacer {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.message-reasoning-card {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message-reasoning-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-reasoning-toggle:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.message-reasoning-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-reasoning-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-reasoning-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message-reasoning-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0.375rem;
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.message-reasoning-toggle:hover .message-reasoning-indicator {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-reasoning-indicator:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.message-reasoning-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-reasoning-expanded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.message-reasoning-body {
|
||||
padding: 0;
|
||||
background-color: var(--surface-code);
|
||||
margin: 0.75rem;
|
||||
}
|
||||
|
||||
.message-reasoning-output {
|
||||
@apply flex flex-col;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.message-reasoning-text {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,55 @@
|
||||
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 {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.prompt-input-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
@@ -19,6 +23,45 @@
|
||||
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 {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
@@ -80,9 +123,6 @@
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
@apply px-4 pb-2 flex justify-between items-center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@apply text-xs;
|
||||
@@ -141,3 +181,26 @@
|
||||
.attachment-download:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,24 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
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 {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
38
packages/ui/src/types/global.d.ts
vendored
38
packages/ui/src/types/global.d.ts
vendored
@@ -1,8 +1,46 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface ElectronDialogFilter {
|
||||
name?: string
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
interface ElectronDialogOptions {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: ElectronDialogFilter[]
|
||||
}
|
||||
|
||||
interface ElectronDialogResult {
|
||||
canceled?: boolean
|
||||
paths?: string[]
|
||||
path?: string | null
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
dialog?: TauriDialogModule
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user