diff --git a/AGENTS.md b/AGENTS.md index 3017aaea..89d70ac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,35 @@ - Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state. - When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions. +## Multi-Language Support (i18n) + +The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings. + +- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code. + - Implementation: `packages/ui/src/lib/i18n/index.tsx` +- **Where messages live:** `packages/ui/src/lib/i18n/messages//` as TypeScript objects (`"flat.dot.keys": "string"`). + - Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time. + - Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts` +- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file. + - Missing translations fall back to English (and finally to the key), so gaps can be easy to miss. +- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`. +- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code. +- **Adding a new language:** add a new `messages//` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`. +- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`). +- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply. + +## File Length Guidelines (Highlight Only) + +We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits. + +- Source files: warn after ~500 lines; target limit ~800 lines +- Test files: highlight after ~1000 lines + +Behavior for agents: +- Do not refactor solely to satisfy these thresholds. +- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count. +- When creating new files, aim to stay under the thresholds unless there's a clear reason. + ## Tooling Preferences - Use the `edit` tool for modifying existing files; prefer it over other editing methods. - Use the `write` tool only when creating new files from scratch. diff --git a/package-lock.json b/package-lock.json index 84f11fc4..b0157bb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -8008,6 +8008,12 @@ "obliterator": "^2.0.1" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -11964,7 +11970,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -11999,7 +12005,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12039,7 +12045,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12047,7 +12053,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", @@ -12064,6 +12070,7 @@ "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "monaco-editor": "^0.52.2", "qrcode": "^1.5.3", "shiki": "^3.13.0", "solid-js": "^1.8.0", diff --git a/package.json b/package.json index a116049b..e93f8d99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 5ff3c319..5cae1b9d 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.10.2", + "minServerVersion": "0.10.3", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron.vite.config.ts b/packages/electron-app/electron.vite.config.ts index 1161176a..1f3f7ede 100644 --- a/packages/electron-app/electron.vite.config.ts +++ b/packages/electron-app/electron.vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite" import solid from "vite-plugin-solid" import { resolve } from "path" +import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js" const uiRoot = resolve(__dirname, "../ui") const uiSrc = resolve(uiRoot, "src") @@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer") const uiRendererEntry = resolve(uiRendererRoot, "index.html") const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html") +function prepareMonacoPublicAssets() { + return { + name: "prepare-monaco-public-assets", + configureServer(server: any) { + copyMonacoPublicAssets({ + uiRendererRoot: uiRendererRoot, + warn: (msg: string) => server.config.logger.warn(msg), + sourceRoots: [ + resolve(__dirname, "../../node_modules/monaco-editor/min/vs"), + resolve(uiRoot, "node_modules/monaco-editor/min/vs"), + ], + }) + }, + buildStart(this: any) { + copyMonacoPublicAssets({ + uiRendererRoot: uiRendererRoot, + warn: (msg: string) => this.warn(msg), + sourceRoots: [ + resolve(__dirname, "../../node_modules/monaco-editor/min/vs"), + resolve(uiRoot, "node_modules/monaco-editor/min/vs"), + ], + }) + }, + } +} + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], @@ -40,7 +67,7 @@ export default defineConfig({ }, renderer: { root: uiRendererRoot, - plugins: [solid()], + plugins: [solid(), prepareMonacoPublicAssets()], css: { postcss: resolve(uiRoot, "postcss.config.js"), }, diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 72c7a4de..80038b02 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise console.warn(`[dev-prep] ${msg}`), + sourceRoots: [ + path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"), + path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"), + ], + }) +} + function ensureUiBuild() { const loadingHtml = path.join(uiDist, "loading.html") if (fs.existsSync(loadingHtml)) { @@ -42,5 +57,11 @@ function copyUiLoadingAssets() { console.log(`[dev-prep] copied loader bundle from ${uiDist}`) } -ensureUiBuild() -copyUiLoadingAssets() +;(async () => { + await ensureMonacoAssets() + ensureUiBuild() + copyUiLoadingAssets() +})().catch((err) => { + console.error("[dev-prep] failed:", err) + process.exit(1) +}) diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index f4daf3b3..fbf0a4a1 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -2,6 +2,7 @@ const fs = require("fs") const path = require("path") const { execSync } = require("child_process") +const { pathToFileURL } = require("url") const root = path.resolve(__dirname, "..") const workspaceRoot = path.resolve(root, "..", "..") @@ -37,6 +38,20 @@ const braceExpansionPath = path.join( const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite") +async function ensureMonacoAssets() { + const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js") + const helperUrl = pathToFileURL(helperPath).href + const { copyMonacoPublicAssets } = await import(helperUrl) + copyMonacoPublicAssets({ + uiRendererRoot: path.join(uiRoot, "src", "renderer"), + warn: (msg) => console.warn(`[prebuild] ${msg}`), + sourceRoots: [ + path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"), + path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"), + ], + }) +} + function ensureServerBuild() { const distPath = path.join(serverRoot, "dist") const publicPath = path.join(serverRoot, "public") @@ -223,12 +238,18 @@ function copyUiLoadingAssets() { console.log(`[prebuild] prepared UI loading assets from ${uiDist}`) } -ensureServerDevDependencies() -ensureUiDevDependencies() -ensureRollupPlatformBinary() -ensureServerDependencies() -ensureServerBuild() -ensureUiBuild() -copyServerArtifacts() -stripNodeModuleBins() -copyUiLoadingAssets() +;(async () => { + ensureServerDevDependencies() + ensureUiDevDependencies() + await ensureMonacoAssets() + ensureRollupPlatformBinary() + ensureServerDependencies() + ensureServerBuild() + ensureUiBuild() + copyServerArtifacts() + stripNodeModuleBins() + copyUiLoadingAssets() +})().catch((err) => { + console.error("[prebuild] failed:", err) + process.exit(1) +}) diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore index 1761634c..4e2f7f13 100644 --- a/packages/ui/.gitignore +++ b/packages/ui/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .vite/ src/renderer/public/logo.png +src/renderer/public/monaco/ diff --git a/packages/ui/package.json b/packages/ui/package.json index 9101436f..83cbeb57 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.10.2", + "version": "0.10.3", "private": true, "license": "MIT", "type": "module", @@ -25,6 +25,7 @@ "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "monaco-editor": "^0.52.2", "qrcode": "^1.5.3", "shiki": "^3.13.0", "solid-js": "^1.8.0", diff --git a/packages/ui/scripts/monaco-public-assets.d.ts b/packages/ui/scripts/monaco-public-assets.d.ts new file mode 100644 index 00000000..3573f9b7 --- /dev/null +++ b/packages/ui/scripts/monaco-public-assets.d.ts @@ -0,0 +1,7 @@ +export type CopyMonacoPublicAssetsParams = { + uiRendererRoot: string + warn?: (message: string) => void + sourceRoots?: string[] +} + +export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void diff --git a/packages/ui/scripts/monaco-public-assets.js b/packages/ui/scripts/monaco-public-assets.js new file mode 100644 index 00000000..6ee3eaf7 --- /dev/null +++ b/packages/ui/scripts/monaco-public-assets.js @@ -0,0 +1,97 @@ +import fs from "fs" +import { resolve } from "path" + +/** + * Copy Monaco's AMD `min/vs` assets into the UI renderer public folder. + * + * Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored + * and generated on demand in dev/build so the repo stays clean. + * + * @param {object} params + * @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`. + * @param {(message: string) => void} [params.warn] Warning logger. + * @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots. + */ +export function copyMonacoPublicAssets(params) { + const uiRendererRoot = params?.uiRendererRoot + if (!uiRendererRoot) { + throw new Error("copyMonacoPublicAssets: uiRendererRoot is required") + } + + const warn = params?.warn ?? ((message) => console.warn(message)) + const publicDir = resolve(uiRendererRoot, "public") + const destRoot = resolve(publicDir, "monaco/vs") + + const candidates = + params?.sourceRoots?.length > 0 + ? params.sourceRoots + : [ + // Workspace root hoisted deps. + resolve(process.cwd(), "node_modules/monaco-editor/min/vs"), + // UI package local deps (covers non-hoisted installs). + resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"), + ] + + const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js"))) + if (!sourceRoot) { + warn("Monaco source directory not found; skipping copy") + return + } + + const copyRecursive = (src, dest) => { + const stat = fs.statSync(src) + if (stat.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }) + for (const entry of fs.readdirSync(src)) { + copyRecursive(resolve(src, entry), resolve(dest, entry)) + } + return + } + fs.copyFileSync(src, dest) + } + + // Keep the working tree clean; these assets are generated. + try { + fs.rmSync(destRoot, { recursive: true, force: true }) + } catch { + // ignore + } + fs.mkdirSync(destRoot, { recursive: true }) + + // Copy core Monaco runtime. + for (const dir of ["base", "editor", "platform"]) { + const src = resolve(sourceRoot, dir) + if (fs.existsSync(src)) { + copyRecursive(src, resolve(destRoot, dir)) + } + } + + // loader.js is required. + copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js")) + + // Copy baseline rich language packages + workers. + for (const lang of ["typescript", "html", "json", "css"]) { + const src = resolve(sourceRoot, "language", lang) + if (fs.existsSync(src)) { + copyRecursive(src, resolve(destRoot, "language", lang)) + } + } + + // Copy baseline basic tokenizers. + for (const lang of ["python", "markdown", "cpp", "kotlin"]) { + const src = resolve(sourceRoot, "basic-languages", lang) + if (fs.existsSync(src)) { + copyRecursive(src, resolve(destRoot, "basic-languages", lang)) + } + } + + // Copy monaco.contribution.js entrypoints (needed by some loads). + const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js") + if (fs.existsSync(monacoContribution)) { + copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js")) + } + const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js") + if (fs.existsSync(underscoreContribution)) { + copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js")) + } +} diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx new file mode 100644 index 00000000..6c6b71de --- /dev/null +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -0,0 +1,116 @@ +import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { loadMonaco } from "../../lib/monaco/setup" +import { getOrCreateTextModel } from "../../lib/monaco/model-cache" +import { inferMonacoLanguageId } from "../../lib/monaco/language" +import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" +import { useTheme } from "../../lib/theme" + +interface MonacoDiffViewerProps { + scopeKey: string + path: string + before: string + after: string + viewMode?: "split" | "unified" + contextMode?: "expanded" | "collapsed" +} + +export function MonacoDiffViewer(props: MonacoDiffViewerProps) { + const { isDark } = useTheme() + let host: HTMLDivElement | undefined + + let diffEditor: any = null + let monaco: any = null + const [ready, setReady] = createSignal(false) + + const disposeEditor = () => { + try { + diffEditor?.setModel(null as any) + } catch { + // ignore + } + try { + diffEditor?.dispose() + } catch { + // ignore + } + diffEditor = null + } + + onMount(() => { + let cancelled = false + void (async () => { + monaco = await loadMonaco() + if (cancelled) return + if (!host || !monaco) return + + monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") + diffEditor = monaco.editor.createDiffEditor(host, { + readOnly: true, + automaticLayout: true, + renderSideBySide: true, + renderSideBySideInlineBreakpoint: 0, + renderMarginRevertIcon: false, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderWhitespace: "selection", + fontSize: 13, + wordWrap: "off", + glyphMargin: false, + folding: false, + // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. + lineNumbersMinChars: 4, + lineDecorationsWidth: 12, + }) + + setReady(true) + })() + + onCleanup(() => { + cancelled = true + setReady(false) + disposeEditor() + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const viewMode = props.viewMode === "unified" ? "unified" : "split" + const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded" + + diffEditor.updateOptions({ + renderSideBySide: viewMode === "split", + renderSideBySideInlineBreakpoint: 0, + hideUnchangedRegions: + contextMode === "collapsed" + ? { enabled: true } + : { enabled: false }, + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const languageId = inferMonacoLanguageId(monaco, props.path) + const beforeKey = `${props.scopeKey}:diff:${props.path}:before` + const afterKey = `${props.scopeKey}:diff:${props.path}:after` + + const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId }) + const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId }) + diffEditor.setModel({ original, modified }) + + void ensureMonacoLanguageLoaded(languageId).then(() => { + try { + monaco.editor.setModelLanguage(original, languageId) + monaco.editor.setModelLanguage(modified, languageId) + } catch { + // ignore + } + }) + }) + + return
+} diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx new file mode 100644 index 00000000..cffecf2d --- /dev/null +++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx @@ -0,0 +1,89 @@ +import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { loadMonaco } from "../../lib/monaco/setup" +import { getOrCreateTextModel } from "../../lib/monaco/model-cache" +import { inferMonacoLanguageId } from "../../lib/monaco/language" +import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" +import { useTheme } from "../../lib/theme" + +interface MonacoFileViewerProps { + scopeKey: string + path: string + content: string +} + +export function MonacoFileViewer(props: MonacoFileViewerProps) { + const { isDark } = useTheme() + let host: HTMLDivElement | undefined + + let editor: any = null + let monaco: any = null + const [ready, setReady] = createSignal(false) + + const disposeEditor = () => { + try { + editor?.setModel(null) + } catch { + // ignore + } + try { + editor?.dispose() + } catch { + // ignore + } + editor = null + } + + onMount(() => { + let cancelled = false + void (async () => { + monaco = await loadMonaco() + if (cancelled) return + if (!host || !monaco) return + + monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") + editor = monaco.editor.create(host, { + value: "", + language: "plaintext", + readOnly: true, + automaticLayout: true, + lineNumbers: "on", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "off", + renderWhitespace: "selection", + fontSize: 13, + }) + + setReady(true) + })() + + onCleanup(() => { + cancelled = true + setReady(false) + disposeEditor() + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !editor) return + monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") + }) + + createEffect(() => { + if (!ready() || !monaco || !editor) return + const languageId = inferMonacoLanguageId(monaco, props.path) + const cacheKey = `${props.scopeKey}:file:${props.path}` + const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId }) + editor.setModel(model) + + void ensureMonacoLanguageLoaded(languageId).then(() => { + try { + monaco.editor.setModelLanguage(model, languageId) + } catch { + // ignore + } + }) + }) + + return
+} diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index a77e3547..281ea084 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -254,12 +254,63 @@ const FolderSelectionView: Component = (props) => { function getDisplayPath(path: string): string { + if (!path) return path + + // macOS: /Users//... if (path.startsWith("/Users/")) { return path.replace(/^\/Users\/[^/]+/, "~") } + + // Linux: /home//... + if (path.startsWith("/home/")) { + return path.replace(/^\/home\/[^/]+/, "~") + } + + // Windows: C:\Users\\... (and the forward-slash variant) + if (/^[A-Za-z]:\\Users\\/.test(path)) { + return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~") + } + if (/^[A-Za-z]:\/Users\//.test(path)) { + return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~") + } + return path } + function looksLikeWindowsPath(value: string): boolean { + if (!value) return false + // Drive letter (C:\...) or UNC (\\server\share\...) + return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value) + } + + function splitFolderPath(rawPath: string): { baseName: string; dirName: string } { + if (!rawPath) return { baseName: "", dirName: "" } + + const isWindows = looksLikeWindowsPath(rawPath) + const trimmed = rawPath.replace(/[\\/]+$/, "") + + // Root edge-cases ("/", "C:\\", "\\\\server\\share\\") + if (!trimmed) { + return { baseName: rawPath, dirName: "" } + } + + if (isWindows && /^[A-Za-z]:$/.test(trimmed)) { + return { baseName: `${trimmed}\\`, dirName: "" } + } + + const lastSlash = trimmed.lastIndexOf("/") + const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1 + const lastSep = Math.max(lastSlash, lastBackslash) + + if (lastSep < 0) { + return { baseName: trimmed, dirName: "" } + } + + const baseName = trimmed.slice(lastSep + 1) || trimmed + const dirName = trimmed.slice(0, lastSep) + return { baseName, dirName } + } + return ( <>
= (props) => {
- {folder.path.split("/").pop()} + {splitFolderPath(folder.path).baseName}
-
- {getDisplayPath(folder.path)} -
-
- {formatRelativeTime(folder.lastAccessed)} +
+ + {getDisplayPath(folder.path)} + + {formatRelativeTime(folder.lastAccessed)}
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f4349229..c16bd408 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -1,7 +1,6 @@ import { For, Show, - batch, createEffect, createMemo, createSignal, @@ -10,69 +9,53 @@ import { type Accessor, type Component, } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk" -import { Accordion } from "@kobalte/core" -import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" import Drawer from "@suid/material/Drawer" import IconButton from "@suid/material/IconButton" import Toolbar from "@suid/material/Toolbar" -import Typography from "@suid/material/Typography" import useMediaQuery from "@suid/material/useMediaQuery" -import MenuIcon from "@suid/icons-material/Menu" -import MenuOpenIcon from "@suid/icons-material/MenuOpen" -import PushPinIcon from "@suid/icons-material/PushPin" -import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" -import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" import type { BackgroundProcess } from "../../../../server/src/api-types" -import type { Session } from "../../types/session" -import { - activeParentSessionId, - activeSessionId as activeSessionMap, - getSessionFamily, - getSessionInfo, - getSessionThreads, - loadMessages, - sessions, - setActiveParentSession, - setActiveSession, -} from "../../stores/sessions" import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" -import { messageStoreBus } from "../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../message-block" import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" -import SessionList from "../session-list" -import KeyboardHint from "../keyboard-hint" import Kbd from "../kbd" import InstanceWelcomeView from "../instance-welcome-view" import InfoView from "../info-view" -import InstanceServiceStatus from "../instance-service-status" -import AgentSelector from "../agent-selector" -import ModelSelector from "../model-selector" -import ThinkingSelector from "../thinking-selector" import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" -import { TodoListView } from "../tool-call/renderers/todo" -import ContextUsagePanel from "../session/context-usage-panel" import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" -import WorktreeSelector from "../worktree-selector" -import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" +import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" +import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" +import SessionSidebar from "./shell/SessionSidebar" +import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" +import RightPanel from "./shell/right-panel/RightPanel" +import { useDrawerChrome } from "./shell/useDrawerChrome" +import { getSessionStatus } from "../../stores/session-status" +import { ShieldAlert } from "lucide-solid" + +import type { LayoutMode } from "./shell/types" import { - SESSION_SIDEBAR_EVENT, - type SessionSidebarRequestAction, - type SessionSidebarRequestDetail, -} from "../../lib/session-sidebar-events" + DEFAULT_SESSION_SIDEBAR_WIDTH, + LEFT_DRAWER_STORAGE_KEY, + RIGHT_DRAWER_STORAGE_KEY, + RIGHT_DRAWER_WIDTH, + clampRightWidth, + clampWidth, +} from "./shell/storage" +import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure" +import { useDrawerResize } from "./shell/useDrawerResize" +import { useSessionCache } from "./shell/useSessionCache" +import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" const log = getLogger("session") @@ -88,66 +71,19 @@ interface InstanceShellProps { tabBarOffset: number } -const DEFAULT_SESSION_SIDEBAR_WIDTH = 340 -const MIN_SESSION_SIDEBAR_WIDTH = 220 -const MAX_SESSION_SIDEBAR_WIDTH = 400 -const RIGHT_DRAWER_WIDTH = 260 -const MIN_RIGHT_DRAWER_WIDTH = 200 -const MAX_RIGHT_DRAWER_WIDTH = 380 -const SESSION_CACHE_LIMIT = 5 -const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" -const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" -const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" -const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" - - - - -type LayoutMode = "desktop" | "tablet" | "phone" - -const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) -const clampRightWidth = (value: number) => Math.min(MAX_RIGHT_DRAWER_WIDTH, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) -const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) -function readStoredPinState(side: "left" | "right", defaultValue: boolean) { - if (typeof window === "undefined") return defaultValue - const stored = window.localStorage.getItem(getPinStorageKey(side)) - if (stored === "true") return true - if (stored === "false") return false - return defaultValue -} -function persistPinState(side: "left" | "right", value: boolean) { - if (typeof window === "undefined") return - window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") -} - const InstanceShell2: Component = (props) => { const { t } = useI18n() const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) - const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH) - const [leftPinned, setLeftPinned] = createSignal(true) - const [leftOpen, setLeftOpen] = createSignal(true) - const [rightPinned, setRightPinned] = createSignal(true) - const [rightOpen, setRightOpen] = createSignal(true) - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - const [drawerHost, setDrawerHost] = createSignal(null) - const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) - const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + const [rightDrawerWidth, setRightDrawerWidth] = createSignal( + typeof window !== "undefined" ? clampRightWidth(window.innerWidth * 0.35) : RIGHT_DRAWER_WIDTH, + ) + const [rightDrawerWidthInitialized, setRightDrawerWidthInitialized] = createSignal(false) const [leftDrawerContentEl, setLeftDrawerContentEl] = createSignal(null) const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal(null) const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal(null) const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal(null) - const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) - const [resizeStartX, setResizeStartX] = createSignal(0) - const [resizeStartWidth, setResizeStartWidth] = createSignal(0) - const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ - "plan", - "background-processes", - "mcp", - "lsp", - "plugins", - ]) + const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) @@ -155,7 +91,20 @@ const InstanceShell2: Component = (props) => { // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) - const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) + const { + allInstanceSessions, + sessionThreads, + activeSessions, + activeSessionIdForInstance, + activeSessionForInstance, + activeSessionDiffs, + latestTodoState, + tokenStats, + backgroundProcessList, + handleSessionSelect, + } = useInstanceSessionContext({ + instanceId: () => props.instance.id, + }) const desktopQuery = useMediaQuery("(min-width: 1280px)") @@ -168,14 +117,47 @@ const InstanceShell2: Component = (props) => { }) const isPhoneLayout = createMemo(() => layoutMode() === "phone") - const leftPinningSupported = createMemo(() => layoutMode() === "desktop") + const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") - const persistPinIfSupported = (side: "left" | "right", value: boolean) => { - if (side === "left" && !leftPinningSupported()) return - if (side === "right" && !rightPinningSupported()) return - persistPinState(side, value) - } + const { setDrawerHost, drawerContainer, measureDrawerHost, floatingTopPx, floatingHeight } = useDrawerHostMeasure( + () => props.tabBarOffset, + ) + + const drawerChrome = useDrawerChrome({ + t, + layoutMode, + leftPinningSupported, + rightPinningSupported, + leftDrawerContentEl, + rightDrawerContentEl, + leftToggleButtonEl, + rightToggleButtonEl, + measureDrawerHost, + }) + + const { + leftPinned, + leftOpen, + rightPinned, + rightOpen, + setLeftOpen, + setRightOpen, + leftDrawerState, + rightDrawerState, + pinLeft: pinLeftDrawer, + unpinLeft: unpinLeftDrawer, + pinRight: pinRightDrawer, + unpinRight: unpinRightDrawer, + closeLeft: closeLeftDrawer, + closeRight: closeRightDrawer, + leftAppBarButtonLabel, + rightAppBarButtonLabel, + leftAppBarButtonIcon, + rightAppBarButtonIcon, + handleLeftAppBarButtonClick, + handleRightAppBarButtonClick, + } = drawerChrome createEffect(() => { const instanceId = props.instance.id @@ -184,43 +166,6 @@ const InstanceShell2: Component = (props) => { }) }) - createEffect(() => { - switch (layoutMode()) { - case "desktop": { - const leftSaved = readStoredPinState("left", true) - const rightSaved = readStoredPinState("right", true) - setLeftPinned(leftSaved) - setLeftOpen(leftSaved) - setRightPinned(rightSaved) - setRightOpen(rightSaved) - break - } - case "tablet": { - const rightSaved = readStoredPinState("right", true) - setLeftPinned(false) - setLeftOpen(false) - setRightPinned(rightSaved) - setRightOpen(rightSaved) - break - } - default: - setLeftPinned(false) - setLeftOpen(false) - setRightPinned(false) - setRightOpen(false) - break - } - }) - - const measureDrawerHost = () => { - if (typeof window === "undefined") return - const host = drawerHost() - if (!host) return - const rect = host.getBoundingClientRect() - setFloatingDrawerTop(rect.top) - setFloatingDrawerHeight(Math.max(0, rect.height)) - } - onMount(() => { if (typeof window === "undefined") return @@ -232,17 +177,27 @@ const InstanceShell2: Component = (props) => { } } + let didLoadRightWidth = false const savedRight = window.localStorage.getItem(RIGHT_DRAWER_STORAGE_KEY) if (savedRight) { const parsed = Number.parseInt(savedRight, 10) if (Number.isFinite(parsed)) { setRightDrawerWidth(clampRightWidth(parsed)) + didLoadRightWidth = true } } + if (!didLoadRightWidth) { + setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35)) + } + + setRightDrawerWidthInitialized(true) + const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) setSessionSidebarWidth((current) => clampWidth(current || width)) + const fallbackRight = window.innerWidth * 0.35 + setRightDrawerWidth((current) => clampRightWidth(current || fallbackRight)) measureDrawerHost() } @@ -251,107 +206,16 @@ const InstanceShell2: Component = (props) => { onCleanup(() => window.removeEventListener("resize", handleResize)) }) - onMount(() => { - if (typeof window === "undefined") return - const handler = (event: Event) => { - const detail = (event as CustomEvent).detail - if (!detail || detail.instanceId !== props.instance.id) return - handleSidebarRequest(detail.action) - } - window.addEventListener(SESSION_SIDEBAR_EVENT, handler) - onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) - }) - - createEffect(() => { - if (typeof window === "undefined") return - window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) - }) + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) + }) createEffect(() => { if (typeof window === "undefined") return window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) }) - createEffect(() => { - props.tabBarOffset - requestAnimationFrame(() => measureDrawerHost()) - }) - - const allInstanceSessions = createMemo>(() => { - return sessions().get(props.instance.id) ?? new Map() - }) - - const sessionThreads = createMemo(() => getSessionThreads(props.instance.id)) - - const activeSessions = createMemo(() => { - const parentId = activeParentSessionId().get(props.instance.id) - if (!parentId) return new Map[number]>() - const sessionFamily = getSessionFamily(props.instance.id, parentId) - return new Map(sessionFamily.map((s) => [s.id, s])) - }) - - const activeSessionIdForInstance = createMemo(() => { - return activeSessionMap().get(props.instance.id) || null - }) - - const parentSessionIdForInstance = createMemo(() => { - return activeParentSessionId().get(props.instance.id) || null - }) - - const activeSessionForInstance = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") return null - return activeSessions().get(sessionId) ?? null - }) - - const activeSessionUsage = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId) return null - const store = messageStore() - return store?.getSessionUsage(sessionId) ?? null - }) - - const activeSessionInfoDetails = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId) return null - return getSessionInfo(props.instance.id, sessionId) ?? null - }) - - const tokenStats = createMemo(() => { - const usage = activeSessionUsage() - const info = activeSessionInfoDetails() - return { - used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, - avail: info?.contextAvailableTokens ?? null, - } - }) - - const latestTodoSnapshot = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") return null - const store = messageStore() - if (!store) return null - const snapshot = store.state.latestTodos[sessionId] - return snapshot ?? null - }) - - const latestTodoState = createMemo(() => { - const snapshot = latestTodoSnapshot() - if (!snapshot) return null - const store = messageStore() - if (!store) return null - const message = store.getMessage(snapshot.messageId) - if (!message) return null - const partRecord = message.parts?.[snapshot.partId] - const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } - if (!part || part.type !== "tool" || part.tool !== "todowrite") return null - const state = part.state - if (!state || state.status !== "completed") return null - return state - }) - - const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id)) - const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatusClass = () => { const status = connectionStatus() @@ -368,6 +232,57 @@ const InstanceShell2: Component = (props) => { return t("instanceShell.connection.unknown") } + const hasPendingRequests = createMemo(() => { + const permissions = getPermissionQueueLength(props.instance.id) + const questions = getQuestionQueueLength(props.instance.id) + return permissions + questions > 0 + }) + + const activeSessionStatusPill = createMemo(() => { + const activeSessionId = activeSessionIdForInstance() + if (!activeSessionId || activeSessionId === "info") return null + + const activeSession = activeSessionForInstance() + const needsPermission = Boolean(activeSession?.pendingPermission) + const needsQuestion = Boolean(activeSession?.pendingQuestion) + const needsInput = needsPermission || needsQuestion + + if (needsInput) { + return { + className: "session-permission", + text: needsPermission + ? t("sessionList.status.needsPermission") + : t("sessionList.status.needsInput"), + showAlertIcon: true, + } + } + + const status = getSessionStatus(props.instance.id, activeSessionId) + const text = + status === "working" + ? t("sessionList.status.working") + : status === "compacting" + ? t("sessionList.status.compacting") + : t("sessionList.status.idle") + + return { + className: `session-${status}`, + text, + showAlertIcon: false, + } + }) + + const renderActiveSessionStatusPill = () => { + const pill = activeSessionStatusPill() + if (!pill) return null + return ( + + {pill.showAlertIcon ?