Merge pull request #150 from NeuralNomadsAI/dev

Release v0.10.3 - Viewer for Changes, Git Diff and workspace files along with UX fixes
This commit is contained in:
Shantur Rathore
2026-02-11 16:09:49 +00:00
committed by GitHub
75 changed files with 6633 additions and 2180 deletions

View File

@@ -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/<locale>/` 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 locales 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/<locale>/` 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.

19
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.10.2",
"version": "0.10.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.10.2",
"minServerVersion": "0.10.3",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -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"),
},

View File

@@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
// In desktop dev workflows we always want the CLI to run in dev mode so it:
// - uses plain HTTP
// - proxies UI requests to the renderer dev server
// Monaco's AMD assets are served from that dev server.
const devMode = !app.isPackaged
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {

View File

@@ -390,7 +390,8 @@ export class CliProcessManager extends EventEmitter {
}
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
args.push("--ui-dev-server", devServer, "--log-level", "debug")
}
return args

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.2",
"version": "0.10.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.10.2",
"version": "0.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.10.2",
"version": "0.10.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.10.2",
"version": "0.10.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -116,12 +116,26 @@ export class WorkspaceRuntime {
folder: options.folder,
binary: options.binaryPath,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
this.logger.debug(
{
workspaceId: options.workspaceId,
spawnArgs: spec.args,
},
"OpenCode spawn args",
)
this.logger.trace(
{
workspaceId: options.workspaceId,
env: redactEnvironment(env),
},
"OpenCode spawn environment",
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.10.2",
"version": "0.10.3",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -3,6 +3,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, "..", "..")
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
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(`[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)
})

View File

@@ -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)
})

View File

@@ -2,3 +2,4 @@ node_modules/
dist/
.vite/
src/renderer/public/logo.png
src/renderer/public/monaco/

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
export type CopyMonacoPublicAssetsParams = {
uiRendererRoot: string
warn?: (message: string) => void
sourceRoots?: string[]
}
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void

View File

@@ -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"))
}
}

View File

@@ -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 <div class="monaco-viewer" ref={host} />
}

View File

@@ -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 <div class="monaco-viewer" ref={host} />
}

View File

@@ -254,12 +254,63 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function getDisplayPath(path: string): string {
if (!path) return path
// macOS: /Users/<name>/...
if (path.startsWith("/Users/")) {
return path.replace(/^\/Users\/[^/]+/, "~")
}
// Linux: /home/<name>/...
if (path.startsWith("/home/")) {
return path.replace(/^\/home\/[^/]+/, "~")
}
// Windows: C:\Users\<name>\... (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 (
<>
<div
@@ -441,14 +492,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state"
import type { Session } from "../../../types/session"
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
import type { DrawerViewState } from "./types"
import { Search } from "lucide-solid"
import IconButton from "@suid/material/IconButton"
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 SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector"
import ThinkingSelector from "../../thinking-selector"
import { getLogger } from "../../../lib/logger"
const log = getLogger("session")
interface SessionSidebarProps {
t: (key: string) => string
instanceId: string
threads: Accessor<SessionThread[]>
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
showSearch: Accessor<boolean>
onToggleSearch: () => void
keyboardShortcuts: Accessor<KeyboardShortcut[]>
isPhoneLayout: Accessor<boolean>
drawerState: Accessor<DrawerViewState>
leftPinned: Accessor<boolean>
onSelectSession: (sessionId: string) => void
onNewSession: () => Promise<void> | void
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onPinLeftDrawer: () => void
onUnpinLeftDrawer: () => void
onCloseLeftDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
enableFilterBar={props.showSearch()}
showHeader={false}
showFooter={false}
/>
<div class="session-sidebar-separator" />
<Show when={props.activeSession()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
<AgentSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
</div>
</>
)}
</Show>
</div>
</div>
)
export default SessionSidebar

View File

@@ -0,0 +1,829 @@
import {
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
type Accessor,
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
import GitChangesTab from "./tabs/GitChangesTab"
import StatusTab from "./tabs/StatusTab"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool,
readStoredEnum,
readStoredPanelWidth,
readStoredRightPanelTab,
} from "../storage"
interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
isPhoneLayout: Accessor<boolean>
rightDrawerWidth: Accessor<number>
rightDrawerWidthInitialized: Accessor<boolean>
rightDrawerState: Accessor<DrawerViewState>
rightPinned: Accessor<boolean>
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan",
"background-processes",
"mcp",
"lsp",
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
const [browserLoading, setBrowserLoading] = createSignal(false)
const [browserError, setBrowserError] = createSignal<string | null>(null)
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
)
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
const [filesListOpen, setFilesListOpen] = createSignal(true)
const [filesListTouched, setFilesListTouched] = createSignal(false)
const [changesListOpen, setChangesListOpen] = createSignal(true)
const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
const layout = listLayoutKey()
if (tab === "changes") {
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
}
if (tab === "git-changes") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
}
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
}
createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey()
layout
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
if (filesPersisted !== null) {
setFilesListOpen(filesPersisted)
setFilesListTouched(true)
} else {
setFilesListOpen(true)
setFilesListTouched(false)
}
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
if (changesPersisted !== null) {
setChangesListOpen(changesPersisted)
setChangesListTouched(true)
} else {
setChangesListOpen(true)
setChangesListTouched(false)
}
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
if (gitPersisted !== null) {
setGitChangesListOpen(gitPersisted)
setGitChangesListTouched(true)
} else {
setGitChangesListOpen(true)
setGitChangesListTouched(false)
}
})
createEffect(() => {
// Default behavior: when nothing is selected, keep the file list open.
// Once the user explicitly toggles it, we stop auto-opening.
if (rightPanelTab() !== "files") return
if (filesListTouched()) return
if (!browserSelectedPath()) {
setFilesListOpen(true)
}
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
const max = Math.min(560, maxByDrawer)
return Math.min(max, Math.max(min, Math.floor(value)))
}
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
createEffect(() => {
if (splitWidthsInitialized()) return
if (!props.rightDrawerWidthInitialized()) return
setSplitWidthsInitialized(true)
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
})
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
if (typeof window === "undefined") return
const key =
mode === "changes"
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
: mode === "git-changes"
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
window.localStorage.setItem(key, String(width))
}
function stopSplitResize() {
setActiveSplitResize(null)
if (typeof document === "undefined") return
splitPointerDrag.stop()
}
function splitMouseMove(event: MouseEvent) {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const delta = event.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitMouseUp() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
function splitTouchMove(event: TouchEvent) {
const mode = activeSplitResize()
if (!mode) return
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
const delta = touch.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitTouchEnd() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
const splitPointerDrag = useGlobalPointerDrag({
onMouseMove: splitMouseMove,
onMouseUp: splitMouseUp,
onTouchMove: splitTouchMove,
onTouchEnd: splitTouchEnd,
})
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
if (typeof document === "undefined") return
setActiveSplitResize(mode)
setSplitResizeStartX(clientX)
setSplitResizeStartWidth(
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
)
splitPointerDrag.start()
}
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
event.preventDefault()
startSplitResize(mode, event.clientX)
}
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startSplitResize(mode, touch.clientX)
}
onCleanup(() => {
stopSplitResize()
})
const worktreeSlugForViewer = createMemo(() => {
const sessionId = props.activeSessionId()
if (sessionId && sessionId !== "info") {
return getWorktreeSlugForSession(props.instanceId, sessionId)
}
return getDefaultWorktreeSlug(props.instanceId)
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const gitMostChangedPath = createMemo<string | null>(() => {
const entries = gitStatusEntries()
if (!Array.isArray(entries) || entries.length === 0) return null
const candidates = entries.filter((item) => item && item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
const score = (item?.added ?? 0) + (item?.removed ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.path === "string" ? best.path : null
})
createEffect(() => {
// Reset tab state when worktree context changes.
worktreeSlugForViewer()
setBrowserPath(".")
setBrowserEntries(null)
setBrowserError(null)
setBrowserSelectedPath(null)
setBrowserSelectedContent(null)
setBrowserSelectedError(null)
setBrowserSelectedLoading(false)
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedPath(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
setGitStatusLoading(true)
setGitStatusError(null)
try {
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
setGitStatusEntries(Array.isArray(list) ? list : [])
} catch (error) {
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
setGitStatusLoading(false)
}
}
async function openGitFile(path: string) {
setGitSelectedPath(path)
setGitSelectedLoading(true)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
const list = gitStatusEntries() || []
const entry = list.find((item) => item.path === path) || null
if (entry?.status === "deleted") {
setGitSelectedError("Deleted file diff is not available yet")
setGitSelectedLoading(false)
return
}
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setGitChangesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
if (afterText === null) {
throw new Error("Unsupported file type")
}
setGitSelectedAfter(afterText)
if (entry?.status === "added") {
setGitSelectedBefore("")
return
}
const diffText =
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
? String((content as any).diff)
: (content as any)?.patch
? buildUnifiedDiffFromSdkPatch((content as any).patch)
: ""
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
if (beforeText === null) {
throw new Error("Unable to calculate diff for this file")
}
setGitSelectedBefore(beforeText)
} catch (error) {
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
setGitSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
const entries = gitStatusEntries()
if (entries === null) return
if (gitSelectedPath()) return
const next = gitMostChangedPath()
if (!next) return
void openGitFile(next)
})
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = gitSelectedPath()
if (selected) {
void openGitFile(selected)
}
}
const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
const best = diffs.reduce((currentBest, item) => {
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
}, diffs[0])
return typeof (best as any)?.file === "string" ? (best as any).file : null
})
createEffect(() => {
const next = bestDiffFile()
if (!next) return
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return
const current = selectedFile()
if (current && diffs.some((d) => d.file === current)) return
setSelectedFile(next)
})
const normalizeBrowserPath = (input: string) => {
const raw = String(input || ".").trim()
if (!raw || raw === "./") return "."
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
return cleaned === "" ? "." : cleaned
}
const getParentPath = (path: string): string | null => {
const current = normalizeBrowserPath(path)
if (current === ".") return null
const parts = current.split("/").filter(Boolean)
parts.pop()
return parts.length ? parts.join("/") : "."
}
const loadBrowserEntries = async (path: string) => {
const normalized = normalizeBrowserPath(path)
setBrowserLoading(true)
setBrowserError(null)
try {
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
setBrowserPath(normalized)
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
} catch (error) {
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
setBrowserEntries([])
} finally {
setBrowserLoading(false)
}
}
const openBrowserFile = async (path: string) => {
setBrowserSelectedPath(path)
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setFilesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
if (browserEntries() !== null) return
void loadBrowserEntries(browserPath())
})
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
if (gitStatusEntries() !== null) return
void loadGitStatus()
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file)
if (closeList) {
setChangesListOpen(false)
}
}
const toggleChangesList = () => {
setChangesListTouched(true)
setChangesListOpen((current) => {
const next = !current
persistListOpen("changes", next)
return next
})
}
const toggleFilesList = () => {
setFilesListTouched(true)
setFilesListOpen((current) => {
const next = !current
persistListOpen("files", next)
return next
})
}
const toggleGitList = () => {
setGitChangesListTouched(true)
setGitChangesListOpen((current) => {
const next = !current
persistListOpen("git-changes", next)
return next
})
}
const refreshFilesTab = async () => {
void loadBrowserEntries(browserPath())
const selected = browserSelectedPath()
if (selected) {
// Refresh file content without altering overlay state.
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
try {
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
}
const browserParentPath = createMemo(() => getParentPath(browserPath()))
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
const openChangesTabFromStatus = (file?: string) => {
if (file) {
setSelectedFile(file)
}
setRightPanelTab("changes")
}
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems())
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
setRightPanelExpandedItems(statusSectionIds)
})
const handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values)
}
const tabClass = (tab: RightPanelTab) =>
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
return (
<div class="flex flex-col h-full" ref={props.setContentEl}>
<div class="right-panel-tab-bar">
<div class="tab-container">
<div class="tab-strip-shortcuts text-primary">
<Show when={props.rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
title={props.t("instanceShell.rightDrawer.toggle.close")}
onClick={props.onCloseRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
>
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
<button
type="button"
role="tab"
class={tabClass("changes")}
aria-selected={rightPanelTab() === "changes"}
onClick={() => setRightPanelTab("changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("git-changes")}
aria-selected={rightPanelTab() === "git-changes"}
onClick={() => setRightPanelTab("git-changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("files")}
aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("files")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("status")}
aria-selected={rightPanelTab() === "status"}
onClick={() => setRightPanelTab("status")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
</button>
</div>
<div class="tab-strip-spacer" />
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}>
<ChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "git-changes"}>
<GitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "files"}>
<FilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path) => void loadBrowserEntries(path)}
onOpenFile={(path) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("files")}
onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "status"}>
<StatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
</Show>
</div>
</div>
)
}
export default RightPanel

View File

@@ -0,0 +1,53 @@
import type { Component } from "solid-js"
import type { DiffContextMode, DiffViewMode } from "../types"
interface DiffToolbarProps {
viewMode: DiffViewMode
contextMode: DiffContextMode
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
return (
<div class="file-viewer-toolbar">
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
aria-pressed={props.viewMode === "split"}
onClick={() => props.onViewModeChange("split")}
>
Split
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
aria-pressed={props.viewMode === "unified"}
onClick={() => props.onViewModeChange("unified")}
>
Unified
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"}
onClick={() => props.onContextModeChange("collapsed")}
title="Hide unchanged regions"
>
Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
</button>
</div>
)
}
export default DiffToolbar

View File

@@ -0,0 +1,16 @@
import type { Component, JSX } from "solid-js"
interface OverlayListProps {
ariaLabel: string
children: JSX.Element
}
const OverlayList: Component<OverlayListProps> = (props) => {
return (
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
<div class="file-list-scroll">{props.children}</div>
</div>
)
}
export default OverlayList

View File

@@ -0,0 +1,70 @@
import { Show, type Component, type JSX } from "solid-js"
import OverlayList from "./OverlayList"
type SplitFilePanelList = {
panel: () => JSX.Element
overlay: () => JSX.Element
}
interface SplitFilePanelProps {
header: JSX.Element
list: SplitFilePanelList
viewer: JSX.Element
listOpen: boolean
onToggleList: () => void
splitWidth: number
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: boolean
overlayAriaLabel: string
}
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? "Hide files" : "Show files"}
</button>
{props.header}
</div>
</div>
<div class="files-tab-body">
<Show
when={!props.isPhoneLayout && props.listOpen}
fallback={props.viewer}
>
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
<div class="file-list-panel">
<div class="file-list-scroll">{props.list.panel()}</div>
</div>
<div
class="file-split-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize file list"
onMouseDown={props.onResizeMouseDown}
onTouchStart={props.onResizeTouchStart}
/>
{props.viewer}
</div>
</Show>
<Show when={props.isPhoneLayout}>
<Show when={props.listOpen}>
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
</Show>
</Show>
</div>
</div>
)
}
export default SplitFilePanel

View File

@@ -0,0 +1,203 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
activeSessionId: Accessor<string | null>
activeSessionDiffs: Accessor<any[] | undefined>
selectedFile: Accessor<string | null>
onSelectFile: (file: string, closeList: boolean) => void
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
const hasSession = Boolean(sessionId && sessionId !== "info")
const diffs = hasSession ? props.activeSessionDiffs() : null
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
return acc
},
{ additions: 0, deletions: 0 },
)
const mostChanged = sorted.length
? sorted.reduce((best, item) => {
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, sorted[0])
: null
// Auto-select the most-changed file if none selected.
const currentSelected = props.selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
const emptyViewerMessage = () => {
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
}
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(file) => (
<MonacoDiffViewer
scopeKey={scopeKey}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
/>
)}
</Show>
</div>
</div>
)
const renderEmptyList = () => (
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
)
const renderListPanel = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout())
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
<span class="file-path-text">{item.file}</span>
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
</Show>
)
const renderListOverlay = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, true)
}}
title={item.file}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
<span class="file-path-text">{item.file}</span>
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
</Show>
)
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={headerPath()}>
<span class="file-path-text">{headerPath()}</span>
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Changes"
/>
)
}
return <>{renderContent()}</>
}
export default ChangesTab

View File

@@ -0,0 +1,191 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
import SplitFilePanel from "../components/SplitFilePanel"
interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string
browserPath: Accessor<string>
browserEntries: Accessor<FileNode[] | null>
browserLoading: Accessor<boolean>
browserError: Accessor<string | null>
browserSelectedPath: Accessor<string | null>
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const FilesTab: Component<FilesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
const sorted = [...entries].sort((a, b) => {
const aDir = a.type === "directory" ? 0 : 1
const bDir = b.type === "directory" ? 0 : 1
if (aDir !== bDir) return aDir - bDir
return String(a.name || "").localeCompare(String(b.name || ""))
})
const parent = props.parentPath()
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return "Loading files..."
return "Select a file to preview"
}
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.browserSelectedLoading()}
fallback={
<Show
when={props.browserSelectedError()}
fallback={
<Show
when={
props.browserSelectedPath() && props.browserSelectedContent() !== null
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(payload) => (
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderList = () => (
<>
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
<span class="file-path-text">..</span>
</div>
</div>
</div>
)}
</Show>
<Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">Loading files...</div>
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
props.onLoadEntries(item.path)
return
}
props.onOpenFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.name}</span>
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</>
)
return (
<SplitFilePanel
header={
<>
<div class="files-tab-stats">
<span class="files-tab-stat">
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
<span class="file-path-text">{headerDisplayedPath()}</span>
</span>
</span>
<Show when={props.browserLoading()}>
<span>Loading</span>
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
list={{ panel: renderList, overlay: renderList }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Files"
/>
)
}
return <>{renderContent()}</>
}
export default FilesTab

View File

@@ -0,0 +1,258 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
activeSessionId: Accessor<string | null>
entries: Accessor<GitFileStatus[] | null>
statusLoading: Accessor<boolean>
statusError: Accessor<string | null>
selectedPath: Accessor<string | null>
selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null>
mostChangedPath: Accessor<string | null>
scopeKey: Accessor<string>
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
const hasSession = Boolean(sessionId && sessionId !== "info")
const entries = hasSession ? props.entries() : null
const sorted = Array.isArray(entries)
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
: []
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0
return acc
},
{ additions: 0, deletions: 0 },
)
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
const emptyViewerMessage = () => {
if (!hasSession) return "Select a session to view changes."
if (entries === null) return "Loading git changes…"
if (nonDeleted.length === 0) return "No git changes yet."
return "No file selected."
}
const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
const selectedEntry =
sorted.find((item) => item.path === selectedPath) ||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.selectedLoading()}
fallback={
<Show
when={props.selectedError()}
fallback={
<Show
when={
selectedEntry &&
props.selectedBefore() !== null &&
props.selectedAfter() !== null &&
selectedEntry.status !== "deleted"
? {
path: selectedEntry.path,
before: props.selectedBefore() as string,
after: props.selectedAfter() as string,
}
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(file) => (
<MonacoDiffViewer
scopeKey={props.scopeKey()}
path={String(file().path || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
/>
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
props.onOpenFile(item.path)
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
</Show>
)
const renderListOverlay = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => props.onOpenFile(item.path)}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
</Show>
)
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={!hasSession || props.statusLoading() || entries === null}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Git Changes"
/>
)
}
return <>{renderContent()}</>
}
export default GitChangesTab

View File

@@ -0,0 +1,294 @@
import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status"
interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
expandedItems: Accessor<string[]>
onExpandedItemsChange: (values: string[]) => void
onOpenChangesTab: (file?: string) => void
}
const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
</div>
)
}
const diffs = props.activeSessionDiffs()
if (diffs === undefined) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
</div>
)
}
if (!Array.isArray(diffs) || diffs.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
</div>
)
}
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
return acc
},
{ additions: 0, deletions: 0 },
)
return (
<div class="flex flex-col gap-3 min-h-0">
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
<span class="flex items-center gap-2">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
</span>
</div>
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
<div class="flex flex-col">
<For each={sorted}>
{(item) => (
<button
type="button"
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
onClick={() => props.onOpenChangesTab(item.file)}
title={props.t("instanceShell.sessionChanges.actions.show")}
>
<div class="flex items-center justify-between gap-3">
<div
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
title={item.file}
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
>
{item.file}
</div>
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
</div>
</div>
</button>
)}
</For>
</div>
</div>
</div>
)
}
const renderPlanSectionContent = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
</div>
)
}
const todoState = props.latestTodoState()
if (!todoState) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
</div>
)
}
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
}
const renderBackgroundProcesses = () => {
const processes = props.backgroundProcessList()
if (processes.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
</div>
)
}
return (
<div class="flex flex-col gap-2">
<For each={processes}>
{(process) => (
<div class="status-process-card">
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>
{props.t("instanceShell.backgroundProcesses.output", {
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
})}
</span>
</Show>
</div>
</div>
<div class="status-process-actions">
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onOpenBackgroundOutput(process)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
title={props.t("instanceShell.backgroundProcesses.actions.output")}
>
<TerminalSquare class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"}
onClick={() => props.onStopBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
>
<XOctagon class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onTerminateBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
)}
</For>
</div>
)
}
const statusSections = [
{
id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
render: renderStatusSessionChanges,
},
{
id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan",
render: renderPlanSectionContent,
},
{
id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
render: renderBackgroundProcesses,
},
{
id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["mcp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["plugins"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
]
return (
<div class="status-tab-container">
<Show when={props.activeSession()}>
{(activeSession) => (
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
)}
</Show>
<Accordion.Root
class="right-panel-accordion"
collapsible
multiple
value={props.expandedItems()}
onChange={props.onExpandedItemsChange}
>
<For each={statusSections}>
{(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger">
<span>{props.t(section.labelKey)}</span>
<ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion.Root>
</div>
)
}
export default StatusTab

View File

@@ -0,0 +1,5 @@
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"

View File

@@ -0,0 +1,92 @@
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
export const MIN_SESSION_SIDEBAR_WIDTH = 220
export const MAX_SESSION_SIDEBAR_WIDTH = 400
export const RIGHT_DRAWER_WIDTH = 260
export const MIN_RIGHT_DRAWER_WIDTH = 200
export const MAX_RIGHT_DRAWER_WIDTH = 1200
export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
export const clampRightWidth = (value: number) => {
const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH
const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax)
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
}
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
export 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
}
export function persistPinState(side: "left" | "right", value: boolean) {
if (typeof window === "undefined") return
window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false")
}
export function readStoredRightPanelTab(
defaultValue: "changes" | "git-changes" | "files" | "status",
): "changes" | "git-changes" | "files" | "status" {
if (typeof window === "undefined") return defaultValue
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
if (stored === "status") return "status"
if (stored === "changes") return "changes"
if (stored === "git-changes") return "git-changes"
if (stored === "files") return "files"
// Migrate from v1 (where the stored values were the internal tab ids).
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
if (legacy === "status") return "status"
if (legacy === "browser") return "files"
if (legacy === "files") return "changes"
return defaultValue
}
export function readStoredPanelWidth(key: string, fallback: number) {
if (typeof window === "undefined") return fallback
const stored = window.localStorage.getItem(key)
if (!stored) return fallback
const parsed = Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
export function readStoredBool(key: string): boolean | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (stored === "true") return true
if (stored === "false") return false
return null
}
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (!stored) return null
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
}

View File

@@ -0,0 +1,3 @@
export type LayoutMode = "desktop" | "tablet" | "phone"
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"

View File

@@ -0,0 +1,260 @@
import {
batch,
createComponent,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
type Accessor,
type JSX,
type Setter,
} from "solid-js"
import MenuIcon from "@suid/icons-material/Menu"
import type { TranslateParams } from "../../../lib/i18n"
import type { DrawerViewState, LayoutMode } from "./types"
import { persistPinState, readStoredPinState } from "./storage"
export interface UseDrawerChromeOptions {
t: (key: string, params?: TranslateParams) => string
layoutMode: Accessor<LayoutMode>
leftPinningSupported: Accessor<boolean>
rightPinningSupported: Accessor<boolean>
leftDrawerContentEl: Accessor<HTMLElement | null>
rightDrawerContentEl: Accessor<HTMLElement | null>
leftToggleButtonEl: Accessor<HTMLElement | null>
rightToggleButtonEl: Accessor<HTMLElement | null>
measureDrawerHost?: () => void
}
export interface DrawerChromeApi {
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
rightPinned: Accessor<boolean>
rightOpen: Accessor<boolean>
setLeftOpen: Setter<boolean>
setRightOpen: Setter<boolean>
leftDrawerState: Accessor<DrawerViewState>
rightDrawerState: Accessor<DrawerViewState>
pinLeft: () => void
unpinLeft: () => void
pinRight: () => void
unpinRight: () => void
closeLeft: () => void
closeRight: () => void
leftAppBarButtonLabel: Accessor<string>
rightAppBarButtonLabel: Accessor<string>
leftAppBarButtonIcon: Accessor<JSX.Element>
rightAppBarButtonIcon: Accessor<JSX.Element>
handleLeftAppBarButtonClick: () => void
handleRightAppBarButtonClick: () => void
}
export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi {
const [leftPinned, setLeftPinned] = createSignal(true)
const [leftOpen, setLeftOpen] = createSignal(true)
const [rightPinned, setRightPinned] = createSignal(true)
const [rightOpen, setRightOpen] = createSignal(true)
const measureDrawerHost = () => options.measureDrawerHost?.()
const focusTarget = (element: HTMLElement | null) => {
if (!element) return
requestAnimationFrame(() => {
element.focus()
})
}
const blurIfInside = (element: HTMLElement | null) => {
if (typeof document === "undefined" || !element) return
const active = document.activeElement as HTMLElement | null
if (active && element.contains(active)) {
active.blur()
}
}
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
if (side === "left" && !options.leftPinningSupported()) return
if (side === "right" && !options.rightPinningSupported()) return
persistPinState(side, value)
}
createEffect(() => {
switch (options.layoutMode()) {
case "desktop": {
const leftSaved = readStoredPinState("left", true)
const rightSaved = readStoredPinState("right", true)
setLeftPinned(leftSaved)
setLeftOpen(leftSaved)
setRightPinned(rightSaved)
setRightOpen(rightSaved)
break
}
case "tablet": {
setLeftPinned(true)
setLeftOpen(true)
setRightPinned(false)
setRightOpen(false)
break
}
default:
setLeftPinned(false)
setLeftOpen(false)
setRightPinned(false)
setRightOpen(false)
break
}
})
const leftDrawerState = createMemo<DrawerViewState>(() => {
if (leftPinned()) return "pinned"
return leftOpen() ? "floating-open" : "floating-closed"
})
const rightDrawerState = createMemo<DrawerViewState>(() => {
if (rightPinned()) return "pinned"
return rightOpen() ? "floating-open" : "floating-closed"
})
const leftAppBarButtonLabel = () => {
const state = leftDrawerState()
if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned")
return options.t("instanceShell.leftDrawer.toggle.open")
}
const rightAppBarButtonLabel = () => {
const state = rightDrawerState()
if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned")
return options.t("instanceShell.rightDrawer.toggle.open")
}
const leftAppBarButtonIcon = () => {
return createComponent(MenuIcon, { fontSize: "small" })
}
const rightAppBarButtonIcon = () => {
return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } })
}
const pinLeft = () => {
blurIfInside(options.leftDrawerContentEl())
batch(() => {
setLeftPinned(true)
setLeftOpen(true)
})
persistPinIfSupported("left", true)
measureDrawerHost()
}
const unpinLeft = () => {
blurIfInside(options.leftDrawerContentEl())
batch(() => {
setLeftPinned(false)
setLeftOpen(true)
})
persistPinIfSupported("left", false)
measureDrawerHost()
}
const pinRight = () => {
blurIfInside(options.rightDrawerContentEl())
batch(() => {
setRightPinned(true)
setRightOpen(true)
})
persistPinIfSupported("right", true)
measureDrawerHost()
}
const unpinRight = () => {
blurIfInside(options.rightDrawerContentEl())
batch(() => {
setRightPinned(false)
setRightOpen(true)
})
persistPinIfSupported("right", false)
measureDrawerHost()
}
const handleLeftAppBarButtonClick = () => {
const state = leftDrawerState()
if (state !== "floating-closed") return
setLeftOpen(true)
measureDrawerHost()
}
const handleRightAppBarButtonClick = () => {
const state = rightDrawerState()
if (state !== "floating-closed") return
setRightOpen(true)
measureDrawerHost()
}
const closeLeft = () => {
if (leftDrawerState() === "pinned") return
blurIfInside(options.leftDrawerContentEl())
setLeftOpen(false)
focusTarget(options.leftToggleButtonEl())
}
const closeRight = () => {
if (rightDrawerState() === "pinned") return
blurIfInside(options.rightDrawerContentEl())
setRightOpen(false)
focusTarget(options.rightToggleButtonEl())
}
const closeFloatingDrawersIfAny = () => {
let handled = false
if (!leftPinned() && leftOpen()) {
setLeftOpen(false)
blurIfInside(options.leftDrawerContentEl())
focusTarget(options.leftToggleButtonEl())
handled = true
}
if (!rightPinned() && rightOpen()) {
setRightOpen(false)
blurIfInside(options.rightDrawerContentEl())
focusTarget(options.rightToggleButtonEl())
handled = true
}
return handled
}
onMount(() => {
if (typeof window === "undefined") return
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
if (!closeFloatingDrawersIfAny()) return
event.preventDefault()
event.stopPropagation()
}
window.addEventListener("keydown", handleEscape, true)
onCleanup(() => window.removeEventListener("keydown", handleEscape, true))
})
return {
leftPinned,
leftOpen,
rightPinned,
rightOpen,
setLeftOpen,
setRightOpen,
leftDrawerState,
rightDrawerState,
pinLeft,
unpinLeft,
pinRight,
unpinRight,
closeLeft,
closeRight,
leftAppBarButtonLabel,
rightAppBarButtonLabel,
leftAppBarButtonIcon,
rightAppBarButtonIcon,
handleLeftAppBarButtonClick,
handleRightAppBarButtonClick,
}
}

View File

@@ -0,0 +1,65 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
type DrawerHostMeasure = {
setDrawerHost: (element: HTMLElement) => void
drawerContainer: () => HTMLElement | undefined
measureDrawerHost: () => void
floatingTopPx: () => string
floatingHeight: () => string
}
export function useDrawerHostMeasure(tabBarOffset: Accessor<number>): DrawerHostMeasure {
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(null)
const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0)
const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0)
const storeDrawerHost = (element: HTMLElement) => {
setDrawerHost(element)
}
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))
}
createEffect(() => {
tabBarOffset()
if (typeof window === "undefined") return
requestAnimationFrame(() => measureDrawerHost())
})
const drawerContainer = () => {
const host = drawerHost()
if (host) return host
if (typeof document !== "undefined") {
return document.body
}
return undefined
}
const fallbackDrawerTop = () => tabBarOffset()
const floatingTop = () => {
const measured = floatingDrawerTop()
if (measured > 0) return measured
return fallbackDrawerTop()
}
const floatingTopPx = () => `${floatingTop()}px`
const floatingHeight = () => {
const measured = floatingDrawerHeight()
if (measured > 0) return `${measured}px`
return `calc(100% - ${floatingTop()}px)`
}
return {
setDrawerHost: storeDrawerHost,
drawerContainer,
measureDrawerHost,
floatingTopPx,
floatingHeight,
}
}

View File

@@ -0,0 +1,113 @@
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
import { useGlobalPointerDrag } from "./useGlobalPointerDrag"
type DrawerResizeSide = "left" | "right"
type DrawerResizeOptions = {
sessionSidebarWidth: Accessor<number>
rightDrawerWidth: Accessor<number>
setSessionSidebarWidth: Setter<number>
setRightDrawerWidth: Setter<number>
clampLeft: (width: number) => number
clampRight: (width: number) => number
measureDrawerHost: () => void
}
type DrawerResizeApi = {
handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void
handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void
}
export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
const [activeResizeSide, setActiveResizeSide] = createSignal<DrawerResizeSide | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const scheduleDrawerMeasure = () => {
if (typeof window === "undefined") {
options.measureDrawerHost()
return
}
requestAnimationFrame(() => options.measureDrawerHost())
}
const applyDrawerWidth = (side: DrawerResizeSide, width: number) => {
if (side === "left") {
options.setSessionSidebarWidth(width)
} else {
options.setRightDrawerWidth(width)
}
scheduleDrawerMeasure()
}
const handleDrawerPointerMove = (clientX: number) => {
const side = activeResizeSide()
if (!side) return
const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
}
function drawerMouseMove(event: MouseEvent) {
event.preventDefault()
handleDrawerPointerMove(event.clientX)
}
function drawerMouseUp() {
stopDrawerResize()
}
function drawerTouchMove(event: TouchEvent) {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
handleDrawerPointerMove(touch.clientX)
}
function drawerTouchEnd() {
stopDrawerResize()
}
const drawerPointerDrag = useGlobalPointerDrag({
onMouseMove: drawerMouseMove,
onMouseUp: drawerMouseUp,
onTouchMove: drawerTouchMove,
onTouchEnd: drawerTouchEnd,
})
function stopDrawerResize() {
setActiveResizeSide(null)
drawerPointerDrag.stop()
}
const startDrawerResize = (side: DrawerResizeSide, clientX: number) => {
setActiveResizeSide(side)
setResizeStartX(clientX)
setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth())
drawerPointerDrag.start()
}
const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => {
event.preventDefault()
startDrawerResize(side, event.clientX)
}
const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startDrawerResize(side, touch.clientX)
}
onCleanup(() => {
stopDrawerResize()
})
return {
handleDrawerResizeMouseDown,
handleDrawerResizeTouchStart,
}
}

View File

@@ -0,0 +1,29 @@
type GlobalPointerDragHandlers = {
onMouseMove: (event: MouseEvent) => void
onMouseUp: (event: MouseEvent) => void
onTouchMove: (event: TouchEvent) => void
onTouchEnd: (event: TouchEvent) => void
}
type GlobalPointerDrag = {
start: () => void
stop: () => void
}
export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag {
const start = () => {
document.addEventListener("mousemove", handlers.onMouseMove)
document.addEventListener("mouseup", handlers.onMouseUp)
document.addEventListener("touchmove", handlers.onTouchMove, { passive: false })
document.addEventListener("touchend", handlers.onTouchEnd)
}
const stop = () => {
document.removeEventListener("mousemove", handlers.onMouseMove)
document.removeEventListener("mouseup", handlers.onMouseUp)
document.removeEventListener("touchmove", handlers.onTouchMove)
document.removeEventListener("touchend", handlers.onTouchEnd)
}
return { start, stop }
}

View File

@@ -0,0 +1,173 @@
import { batch, createMemo, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { Session } from "../../../types/session"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessionInfo,
getSessionThreads,
sessions,
setActiveParentSession,
setActiveSession,
} from "../../../stores/sessions"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { getBackgroundProcesses } from "../../../stores/background-processes"
import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types"
type InstanceSessionContextOptions = {
instanceId: Accessor<string>
}
type InstanceSessionContextState = {
// Session collections and selections
allInstanceSessions: Accessor<Map<string, Session>>
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
activeSessions: Accessor<Map<string, SessionFamilyMember>>
activeSessionIdForInstance: Accessor<string | null>
parentSessionIdForInstance: Accessor<string | null>
activeSessionForInstance: Accessor<SessionFamilyMember | null>
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
// Usage / info summaries
activeSessionUsage: Accessor<SessionUsageState | null>
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
tokenStats: Accessor<{ used: number; avail: number | null }>
// Todo state
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
latestTodoState: Accessor<ToolState | null>
// Background processes
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
// Controller
handleSessionSelect: (sessionId: string) => void
}
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
return sessions().get(options.instanceId()) ?? new Map()
})
const sessionThreads = createMemo(() => getSessionThreads(options.instanceId()))
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(options.instanceId())
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
const sessionFamily = getSessionFamily(options.instanceId(), parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
return activeSessionMap().get(options.instanceId()) || null
})
const parentSessionIdForInstance = createMemo(() => {
return activeParentSessionId().get(options.instanceId()) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const activeSessionDiffs = createMemo(() => {
const session = activeSessionForInstance()
return session?.diff
})
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(options.instanceId(), 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<ToolState | null>(() => {
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(options.instanceId()))
const handleSessionSelect = (sessionId: string) => {
const instanceId = options.instanceId()
if (sessionId === "info") {
setActiveSession(instanceId, sessionId)
return
}
const session = allInstanceSessions().get(sessionId)
if (!session) return
if (session.parentId === null) {
setActiveParentSession(instanceId, sessionId)
return
}
const parentId = session.parentId
if (!parentId) return
batch(() => {
setActiveParentSession(instanceId, parentId)
setActiveSession(instanceId, sessionId)
})
}
return {
allInstanceSessions,
sessionThreads,
activeSessions,
activeSessionIdForInstance,
parentSessionIdForInstance,
activeSessionForInstance,
activeSessionDiffs,
activeSessionUsage,
activeSessionInfoDetails,
tokenStats,
latestTodoSnapshot,
latestTodoState,
backgroundProcessList,
handleSessionSelect,
}
}

View File

@@ -0,0 +1,99 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../../message-block"
import { getLogger } from "../../../lib/logger"
const log = getLogger("session")
const SESSION_CACHE_LIMIT = 5
type SessionCacheOptions = {
instanceId: Accessor<string>
instanceSessions: Accessor<Map<string, unknown>>
activeSessionId: Accessor<string | null>
}
type SessionCacheState = {
cachedSessionIds: Accessor<string[]>
}
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
const evictSession = (sessionId: string) => {
if (!sessionId) return
const instanceId = options.instanceId()
log.info("Evicting cached session", { instanceId, sessionId })
const store = messageStoreBus.getInstance(instanceId)
store?.clearSession(sessionId)
clearSessionRenderCache(instanceId, sessionId)
}
const scheduleEvictions = (ids: string[]) => {
if (!ids.length) return
setPendingEvictions((current) => {
const existing = new Set(current)
const next = [...current]
ids.forEach((id) => {
if (!existing.has(id)) {
next.push(id)
existing.add(id)
}
})
return next
})
}
createEffect(() => {
const pending = pendingEvictions()
if (!pending.length) return
const cached = new Set(cachedSessionIds())
const remaining: string[] = []
pending.forEach((id) => {
if (cached.has(id)) {
remaining.push(id)
} else {
evictSession(id)
}
})
if (remaining.length !== pending.length) {
setPendingEvictions(remaining)
}
})
createEffect(() => {
const instanceSessions = options.instanceSessions()
const activeId = options.activeSessionId()
setCachedSessionIds((current) => {
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
const touch = (id: string | null) => {
if (!id || id === "info") return
if (!instanceSessions.has(id)) return
const index = next.indexOf(id)
if (index !== -1) {
next.splice(index, 1)
}
next.unshift(id)
}
touch(activeId)
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
scheduleEvictions(removed)
}
return trimmed
})
})
return {
cachedSessionIds,
}
}

View File

@@ -0,0 +1,109 @@
import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import {
SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction,
type SessionSidebarRequestDetail,
} from "../../../lib/session-sidebar-events"
interface PendingSidebarAction {
action: SessionSidebarRequestAction
id: number
}
interface UseSessionSidebarRequestsOptions {
instanceId: Accessor<string>
sidebarContentEl: Accessor<HTMLElement | null>
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
setLeftOpen: (next: boolean) => void
measureDrawerHost: () => void
}
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent(
new KeyboardEvent("keydown", {
key: options.key,
code: options.code,
keyCode: options.keyCode,
which: options.keyCode,
bubbles: true,
cancelable: true,
}),
)
}
const focusAgentSelectorControl = () => {
const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null
if (!agentTrigger) return false
agentTrigger.focus()
setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10)
return true
}
const focusModelSelectorControl = () => {
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-model-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
const focusVariantSelectorControl = () => {
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
createEffect(() => {
const pending = pendingSidebarAction()
if (!pending) return
const action = pending.action
const contentReady = Boolean(options.sidebarContentEl())
if (!contentReady) {
return
}
if (action === "show-session-list") {
setPendingSidebarAction(null)
return
}
const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) {
setPendingSidebarAction(null)
}
})
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ })
if (!options.leftPinned() && !options.leftOpen()) {
options.setLeftOpen(true)
options.measureDrawerHost()
}
}
onMount(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<SessionSidebarRequestDetail>).detail
if (!detail || detail.instanceId !== options.instanceId()) return
handleSidebarRequest(detail.action)
}
window.addEventListener(SESSION_SIDEBAR_EVENT, handler)
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
})
return {
handleSidebarRequest,
pendingSidebarAction,
}
}

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -1010,10 +1010,13 @@ function ReasoningCard(props: ReasoningCardProps) {
const toggle = () => setExpanded((prev) => !prev)
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const hasDeleteTarget = () => Boolean(props.partId)
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDelete = async (event: Event) => {
const handleDelete = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDelete()) return
@@ -1033,56 +1036,66 @@ function ReasoningCard(props: ReasoningCardProps) {
return (
<div class="message-reasoning-card">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>{t("messageBlock.reasoning.thinkingLabel")}</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)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
<span class="message-reasoning-meta">
<span class="message-reasoning-indicator">
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
<div class="message-reasoning-header">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>{t("messageBlock.reasoning.thinkingLabel")}</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)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
</button>
<div class="message-reasoning-actions">
<button
type="button"
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggle()
}}
aria-label={viewHideLabel()}
title={viewHideLabel()}
>
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
</button>
<Show when={hasDeleteTarget()}>
<span
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
role="button"
tabIndex={0}
<button
type="button"
class="message-action-button"
onClick={handleDelete}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
handleDelete(event)
}
}}
disabled={!canDelete()}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</span>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<span class="message-reasoning-time">{timestamp()}</span>
</span>
</button>
</div>
</div>
<Show when={expanded()}>
<div class="message-reasoning-expanded">

View File

@@ -268,87 +268,84 @@ export default function MessageItem(props: MessageItemProps) {
return (
<div class={containerClass()}>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title={t("messageItem.actions.revert")}
aria-label={t("messageItem.actions.revert")}
>
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={deletableTextPartId()}>
{(partId) => (
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
</div>
</Show>
<Show when={!isUser()}>
<div class="message-action-group">
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<div class="message-item-header-row message-item-header-row--top">
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<Show when={deletableTextPartId()}>
{(partId) => (
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
onClick={handleRevert}
title={t("messageItem.actions.revert")}
aria-label={t("messageItem.actions.revert")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
<Show when={!isUser()}>
<div class="message-action-group">
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={deletableTextPartId()}>
{(partId) => (
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
<Show when={agentMeta()}>
{(meta) => (
<div class="message-item-header-row message-item-header-row--bottom">
<span class="message-agent-meta">{meta()}</span>
</div>
)}
</Show>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { For, Show, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import type { Attachment } from "../../types/attachment"
import { useI18n } from "../../lib/i18n"
interface PromptAttachmentsBarProps {
attachments: Attachment[]
onRemoveAttachment: (attachmentId: string) => void
onExpandTextAttachment: (attachmentId: string) => void
}
const PromptAttachmentsBar: Component<PromptAttachmentsBarProps> = (props) => {
const { t } = useI18n()
return (
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
<For each={props.attachments}>
{(attachment) => {
const isText = attachment.source.type === "text"
return (
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
<span class="font-mono">{attachment.display}</span>
<Show when={isText}>
<button
type="button"
class="attachment-expand"
onClick={() => props.onExpandTextAttachment(attachment.id)}
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title={t("sessionView.attachments.insertPastedTextTitle")}
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => props.onRemoveAttachment(attachment.id)}
aria-label={t("sessionView.attachments.removeAriaLabel")}
>
×
</button>
</div>
)
}}
</For>
</div>
)
}
export default PromptAttachmentsBar

View File

@@ -0,0 +1,72 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]`
}
export function formatImagePlaceholder(value: string | number) {
return `[Image #${value}]`
}
export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g
}
export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g
}
export function createMentionRegex() {
return /@(\S+)/g
}
export const pastedDisplayCounterRegex = /pasted #(\d+)/
export const imageDisplayCounterRegex = /Image #(\d+)/
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
let highestPaste = 0
let highestImage = 0
for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
return { highestPaste, highestImage }
}

View File

@@ -0,0 +1,26 @@
import type { Attachment } from "../../types/attachment"
export type PromptMode = "normal" | "shell"
export type ExpandState = "normal" | "expanded"
export type PickerMode = "mention" | "command"
export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void
}
export interface PromptInputProps {
instanceId: string
instanceFolder: string
sessionId: string
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean
escapeInDebounce?: boolean
isSessionBusy?: boolean
onAbortSession?: () => Promise<void>
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
}

View File

@@ -0,0 +1,296 @@
import { createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment"
import {
bracketedImageDisplayCounterRegex,
findHighestAttachmentCounters,
formatImagePlaceholder,
formatPastedPlaceholder,
pastedDisplayCounterRegex,
} from "./attachmentPlaceholders"
type PromptAttachmentsOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
}
type PromptAttachments = {
attachments: Accessor<Attachment[]>
pasteCount: Accessor<number>
imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean>
handleDragOver: (e: DragEvent) => void
handleDragLeave: (e: DragEvent) => void
handleDrop: (e: DragEvent) => void
handleRemoveAttachment: (attachmentId: string) => void
handleExpandTextAttachment: (attachment: Attachment) => void
}
export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments {
const attachments = () => getAttachments(options.instanceId(), options.sessionId())
const [isDragging, setIsDragging] = createSignal(false)
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
setPasteCount(highestPaste)
setImageCount(highestImage)
}
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId)
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (attachment) {
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
if (attachment.source.type === "file") {
if (attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (imageMatch) {
const placeholder = formatImagePlaceholder(imageMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
}
} else {
const filename = attachment.filename
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
}
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
}
}
options.setPrompt(newPrompt)
}
}
function handleExpandTextAttachment(attachment: Attachment) {
if (attachment.source.type !== "text") return
const textarea = options.getTextarea()
const value = attachment.source.value
const match = attachment.display.match(pastedDisplayCounterRegex)
const placeholder = match ? formatPastedPlaceholder(match[1]) : null
const currentText = options.prompt()
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
options.setPrompt(nextText)
removeAttachment(options.instanceId(), options.sessionId(), attachment.id)
if (textarea) {
setTimeout(() => {
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}, 0)
}
}
async function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.startsWith("image/")) {
e.preventDefault()
const blob = item.getAsFile()
if (!blob) continue
const count = imageCount() + 1
setImageCount(count)
const reader = new FileReader()
reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png`
const attachment = createFileAttachment(
filename,
filename,
"image/png",
new TextEncoder().encode(base64Data),
options.instanceFolder(),
)
attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = display
addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
}
reader.readAsDataURL(blob)
return
}
}
const pastedText = e.clipboardData?.getData("text/plain")
if (!pastedText) return
const lineCount = pastedText.split("\n").length
const charCount = pastedText.length
const isLongPaste = charCount > 150 || lineCount > 3
if (isLongPaste) {
e.preventDefault()
const count = pasteCount() + 1
setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
const display = `pasted #${count} (${summary})`
const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename)
addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const file = files[i]
const path = (file as File & { path?: string }).path || file.name
const filename = file.name
const mime = file.type || "text/plain"
const createAndStoreAttachment = (previewUrl?: string) => {
const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder())
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
attachment.url = previewUrl
}
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(result)
}
reader.readAsDataURL(file)
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(dataUrl)
}
reader.readAsDataURL(file)
} else {
createAndStoreAttachment()
}
}
options.getTextarea()?.focus()
}
return {
attachments,
pasteCount,
imageCount,
syncAttachmentCounters,
handlePaste,
isDragging,
handleDragOver,
handleDragLeave,
handleDrop,
handleRemoveAttachment,
handleExpandTextAttachment,
}
}

View File

@@ -0,0 +1,272 @@
import type { Accessor } from "solid-js"
import type { Attachment } from "../../types/attachment"
import type { PromptMode } from "./types"
import {
createImagePlaceholderRegex,
createMentionRegex,
createPastedPlaceholderRegex,
} from "./attachmentPlaceholders"
export type UsePromptKeyDownOptions = {
getTextarea: () => HTMLTextAreaElement | null
prompt: Accessor<string>
setPrompt: (v: string) => void
mode: Accessor<PromptMode>
setMode: (m: PromptMode) => void
isPickerOpen: Accessor<boolean>
closePicker: () => void
ignoredAtPositions: Accessor<Set<number>>
setIgnoredAtPositions: (next: Set<number> | ((s: Set<number>) => Set<number>)) => void
getAttachments: Accessor<Attachment[]>
removeAttachment: (attachmentId: string) => void
submitOnEnter: Accessor<boolean>
onSend: () => void
selectPreviousHistory: (force?: boolean) => boolean
selectNextHistory: (force?: boolean) => boolean
}
export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
const insertNewlineAtCursor = () => {
const textarea = options.getTextarea()
const current = options.prompt()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
const nextCursor = start + 1
options.setPrompt(nextValue)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
nextTextarea.focus()
nextTextarea.setSelectionRange(nextCursor, nextCursor)
}, 0)
}
return function handleKeyDown(e: KeyboardEvent) {
const textarea = options.getTextarea()
if (!textarea) return
const currentText = options.prompt()
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const isShellMode = options.mode() === "shell"
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
e.preventDefault()
options.setMode("shell")
return
}
if (options.isPickerOpen() && e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
options.closePicker()
return
}
if (isShellMode) {
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
options.setMode("normal")
return
}
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
e.preventDefault()
options.setMode("normal")
return
}
}
if (e.key === "Backspace" || e.key === "Delete") {
const cursorPos = textarea.selectionStart
const text = currentText
const pastePlaceholderRegex = createPastedPlaceholderRegex()
let pasteMatch
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
const placeholderStart = pasteMatch.index
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
const pasteNumber = pasteMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
const isSelected =
textarea.selectionStart <= placeholderStart &&
textarea.selectionEnd >= placeholderEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
e.preventDefault()
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
)
if (attachment) {
options.removeAttachment(attachment.id)
}
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(placeholderStart, placeholderStart)
}, 0)
return
}
}
const imagePlaceholderRegex = createImagePlaceholderRegex()
let imageMatch
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
const placeholderStart = imageMatch.index
const placeholderEnd = imageMatch.index + imageMatch[0].length
const imageNumber = imageMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
const isSelected =
textarea.selectionStart <= placeholderStart &&
textarea.selectionEnd >= placeholderEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
e.preventDefault()
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`),
)
if (attachment) {
options.removeAttachment(attachment.id)
}
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(placeholderStart, placeholderStart)
}, 0)
return
}
}
const mentionRegex = createMentionRegex()
let mentionMatch
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
const mentionStart = mentionMatch.index
const mentionEnd = mentionMatch.index + mentionMatch[0].length
const name = mentionMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
const isSelected =
textarea.selectionStart <= mentionStart &&
textarea.selectionEnd >= mentionEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
)
if (attachment) {
e.preventDefault()
options.removeAttachment(attachment.id)
options.setIgnoredAtPositions((prev) => {
const next = new Set(prev)
next.delete(mentionStart)
return next
})
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(mentionStart, mentionStart)
}, 0)
return
}
}
}
}
if (e.key === "Enter") {
const isModified = e.metaKey || e.ctrlKey
// If the picker is open, Enter should select from it.
if (!isModified && options.isPickerOpen()) {
return
}
if (options.submitOnEnter()) {
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
if (isModified) {
e.preventDefault()
e.stopPropagation()
insertNewlineAtCursor()
return
}
// Shift+Enter always inserts a newline.
if (e.shiftKey) {
// If the picker is open, avoid selecting an item on Enter.
if (options.isPickerOpen()) {
e.stopPropagation()
}
return
}
e.preventDefault()
options.onSend()
return
}
// Default: Cmd/Ctrl+Enter submits.
if (isModified) {
e.preventDefault()
if (options.isPickerOpen()) {
options.closePicker()
}
options.onSend()
return
}
}
if (e.key === "ArrowUp") {
const handled = options.selectPreviousHistory()
if (handled) {
e.preventDefault()
return
}
}
if (e.key === "ArrowDown") {
const handled = options.selectNextHistory()
if (handled) {
e.preventDefault()
return
}
}
}
}

View File

@@ -0,0 +1,272 @@
import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types"
type PickerItem =
| { type: "agent"; agent: Agent }
| { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } }
| { type: "command"; command: SDKCommand }
type PromptPickerOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
instanceAgents: Accessor<Agent[]>
commands: Accessor<SDKCommand[]>
}
type PromptPickerController = {
showPicker: Accessor<boolean>
pickerMode: Accessor<PickerMode>
searchQuery: Accessor<string>
atPosition: Accessor<number | null>
ignoredAtPositions: Accessor<Set<number>>
setShowPicker: Setter<boolean>
setPickerMode: Setter<PickerMode>
setSearchQuery: Setter<string>
setAtPosition: Setter<number | null>
setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem) => void
handlePickerClose: () => void
}
export function usePromptPicker(options: PromptPickerOptions): PromptPickerController {
const [showPicker, setShowPicker] = createSignal(false)
const [pickerMode, setPickerMode] = createSignal<PickerMode>("mention")
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
const value = target.value
options.setPrompt(value)
const cursorPos = target.selectionStart
// Slash command picker (only when editing the command token: "/<query>")
if (value.startsWith("/") && cursorPos >= 1) {
const firstWhitespaceIndex = value.slice(1).search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
if (cursorPos <= tokenEnd) {
setPickerMode("command")
setAtPosition(0)
setSearchQuery(value.substring(1, cursorPos))
setShowPicker(true)
return
}
}
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
const previousAtPosition = atPosition()
if (lastAtIndex === -1) {
setIgnoredAtPositions(new Set<number>())
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
setIgnoredAtPositions((prev) => {
const next = new Set(prev)
next.delete(previousAtPosition)
return next
})
}
if (lastAtIndex !== -1) {
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setPickerMode("mention")
setAtPosition(lastAtIndex)
setSearchQuery(textAfterAt)
setShowPicker(true)
}
return
}
}
setShowPicker(false)
setAtPosition(null)
}
function handlePickerSelect(item: PickerItem) {
const textarea = options.getTextarea()
if (item.type === "command") {
const name = item.command.name
const currentPrompt = options.prompt()
const afterSlash = currentPrompt.slice(1)
const firstWhitespaceIndex = afterSlash.search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
const before = ""
const after = currentPrompt.substring(tokenEnd)
const newPrompt = before + `/${name} ` + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = `/${name} `.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
nextTextarea.focus()
}
}, 0)
} else if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "agent" && att.source.name === agentName,
)
if (!alreadyAttached) {
const attachment = createAgentAttachment(agentName)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${agentName}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
} else if (item.type === "file") {
const displayPath = item.file.path
const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
}
setShowPicker(false)
setAtPosition(null)
setSearchQuery("")
textarea?.focus()
}
function handlePickerClose() {
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowPicker(false)
setAtPosition(null)
setSearchQuery("")
setTimeout(() => options.getTextarea()?.focus(), 0)
}
return {
showPicker,
pickerMode,
searchQuery,
atPosition,
ignoredAtPositions,
setShowPicker,
setPickerMode,
setSearchQuery,
setAtPosition,
setIgnoredAtPositions,
handleInput,
handlePickerSelect,
handlePickerClose,
}
}

View File

@@ -0,0 +1,203 @@
import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
import { addToHistory, getHistory } from "../../stores/message-history"
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
import { getLogger } from "../../lib/logger"
const log = getLogger("actions")
type GetTextarea = () => HTMLTextAreaElement | undefined | null
type PromptStateOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
onSessionDraftLoaded?: (draft: string) => void
}
type HistorySelectOptions = {
force?: boolean
isPickerOpen: boolean
getTextarea: GetTextarea
}
type PromptState = {
prompt: Accessor<string>
setPrompt: (value: string) => void
clearPrompt: () => void
draftLoadedNonce: Accessor<number>
history: Accessor<string[]>
historyIndex: Accessor<number>
historyDraft: Accessor<string | null>
resetHistoryNavigation: () => void
clearHistoryDraft: () => void
recordHistoryEntry: (entry: string) => Promise<void>
selectPreviousHistory: (options: HistorySelectOptions) => boolean
selectNextHistory: (options: HistorySelectOptions) => boolean
}
const HISTORY_LIMIT = 100
export function usePromptState(options: PromptStateOptions): PromptState {
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
const setPrompt = (value: string) => {
setPromptInternal(value)
// Persist drafts only when the user is at the "fresh" position (not browsing history).
// This keeps the bottom-of-history draft stable even if the user edits recalled history entries.
if (historyIndex() === -1) {
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
}
}
const clearPrompt = () => {
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
setPromptInternal("")
}
const resetHistoryNavigation = () => {
setHistoryIndex(-1)
setHistoryDraft(null)
}
const clearHistoryDraft = () => {
setHistoryDraft(null)
}
createEffect(
on(
() => `${options.instanceId()}:${options.sessionId()}`,
() => {
const instanceId = options.instanceId()
const sessionId = options.sessionId()
onCleanup(() => {
// Persist the previous session's draft when switching sessions.
setSessionDraftPrompt(instanceId, sessionId, prompt())
})
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
setPromptInternal(storedPrompt)
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
resetHistoryNavigation()
setDraftLoadedNonce((prev) => prev + 1)
options.onSessionDraftLoaded?.(storedPrompt)
},
),
)
onMount(() => {
void (async () => {
const loaded = await getHistory(options.instanceFolder())
setHistory(loaded)
})()
})
const recordHistoryEntry = async (entry: string) => {
try {
await addToHistory(options.instanceFolder(), entry)
setHistory((prev) => {
const next = [entry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
return next
})
setHistoryIndex(-1)
} catch (historyError) {
log.error("Failed to update prompt history:", historyError)
}
}
const canUseHistory = (selectOptions: HistorySelectOptions) => {
if (selectOptions.force) return true
if (selectOptions.isPickerOpen) return false
const textarea = selectOptions.getTextarea()
if (!textarea) return false
// Only require the cursor to be at the buffer start when *entering* history navigation.
// Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown
// regardless of cursor position (we focus the end of the entry).
if (historyIndex() !== -1) return true
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
}
const focusTextareaEnd = (getTextarea: GetTextarea) => {
const textarea = getTextarea()
if (!textarea) return
setTimeout(() => {
const next = getTextarea()
if (!next) return
const pos = next.value.length
next.setSelectionRange(pos, pos)
next.focus()
}, 0)
}
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
focusTextareaEnd(selectOptions.getTextarea)
return true
}
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
} else {
setHistoryIndex(-1)
const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId())
setPrompt(draft ?? "")
setHistoryDraft(null)
}
focusTextareaEnd(selectOptions.getTextarea)
return true
}
return {
prompt,
setPrompt,
clearPrompt,
draftLoadedNonce,
history,
historyIndex,
historyDraft,
resetHistoryNavigation,
clearHistoryDraft,
recordHistoryEntry,
selectPreviousHistory,
selectNextHistory,
}
}

View File

@@ -1,12 +1,11 @@
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import { Show, createMemo, createEffect, on, type Component } from "solid-js"
import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import type { Attachment as PromptAttachment } from "../../types/attachment"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
@@ -15,6 +14,7 @@ import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
const log = getLogger("session")
@@ -53,52 +53,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
function handleExpandTextAttachment(attachment: PromptAttachment) {
if (attachment.source.type !== "text") return
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
const value = attachment.source.value
const match = attachment.display.match(/pasted #(\d+)/)
const placeholder = match ? `[pasted #${match[1]}]` : null
const currentText = textarea?.value ?? ""
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
if (textarea) {
textarea.value = nextText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}
removeAttachment(props.instanceId, props.sessionId, attachment.id)
}
let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
@@ -135,6 +92,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
// Defer until the session pane is visible and the textarea is mounted.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (promptInputApi) {
promptInputApi.focus()
return
}
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
if (!textarea) return
if (textarea.disabled) return
@@ -149,8 +111,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
},
),
)
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
createEffect(() => {
const currentSession = session()
if (currentSession) {
@@ -158,18 +119,31 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
})
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
quoteHandler = handler
function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api
if (pendingPromptText) {
api.setPromptText(pendingPromptText, { focus: true })
pendingPromptText = null
}
if (pendingSelectionInsert) {
api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode)
pendingSelectionInsert = null
}
return () => {
if (quoteHandler === handler) {
quoteHandler = null
if (promptInputApi === api) {
promptInputApi = null
}
}
}
function handleQuoteSelection(text: string, mode: "quote" | "code") {
if (quoteHandler) {
quoteHandler(text, mode)
function handleQuoteSelection(text: string, mode: PromptInsertMode) {
if (promptInputApi) {
promptInputApi.insertSelection(text, mode)
} else {
pendingSelectionInsert = { text, mode }
}
}
@@ -230,14 +204,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
)
const restoredText = getUserMessageText(messageId)
if (restoredText) {
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
} catch (error) {
log.error("Failed to revert message", error)
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
@@ -271,14 +244,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
if (restoredText) {
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
} catch (error) {
log.error("Failed to fork session", error)
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
@@ -327,39 +299,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
<For each={attachments()}>
{(attachment) => {
const isText = attachment.source.type === "text"
return (
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
<span class="font-mono">{attachment.display}</span>
<Show when={isText}>
<button
type="button"
class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)}
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title={t("sessionView.attachments.insertPastedTextTitle")}
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label={t("sessionView.attachments.removeAriaLabel")}
>
×
</button>
</div>
)
}}
</For>
</div>
</Show>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}
@@ -371,11 +317,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
</div>
)
}}
registerPromptInputApi={registerPromptInputApi}
/>
</div>
)
}}
</Show>
)
}

View File

@@ -86,12 +86,32 @@ export const instanceMessages = {
"instanceShell.empty.description": "Select a session to view messages",
"instanceShell.rightPanel.title": "Status Panel",
"instanceShell.rightPanel.tabs.changes": "Session Changes",
"instanceShell.rightPanel.tabs.gitChanges": "Git Changes",
"instanceShell.rightPanel.tabs.files": "Files",
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
"instanceShell.sessionChanges.loading": "Fetching session changes...",
"instanceShell.sessionChanges.empty": "No session changes yet.",
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
"instanceShell.filesShell.viewerTitle": "Change viewer",
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
"instanceShell.filesShell.viewerEmpty": "No file selected.",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -86,12 +86,30 @@ export const instanceMessages = {
"instanceShell.empty.description": "Selecciona una sesión para ver mensajes",
"instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
"instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
"instanceShell.rightPanel.sections.mcp": "Servidores MCP",
"instanceShell.rightPanel.sections.lsp": "Servidores LSP",
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.",
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...",
"instanceShell.sessionChanges.empty": "Aun no hay cambios.",
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
"instanceShell.filesShell.viewerTitle": "Visor de cambios",
"instanceShell.filesShell.viewerPlaceholder": "La vista detallada se agregará en el siguiente paso.",
"instanceShell.filesShell.viewerEmpty": "Ningún archivo seleccionado.",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View File

@@ -86,12 +86,30 @@ export const instanceMessages = {
"instanceShell.empty.description": "Sélectionnez une session pour voir les messages",
"instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications",
"instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan",
"instanceShell.rightPanel.sections.mcp": "Serveurs MCP",
"instanceShell.rightPanel.sections.lsp": "Serveurs LSP",
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.",
"instanceShell.sessionChanges.loading": "Récupération des changements...",
"instanceShell.sessionChanges.empty": "Aucun changement pour l'instant.",
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
"instanceShell.filesShell.viewerTitle": "Visionneuse de changements",
"instanceShell.filesShell.viewerPlaceholder": "Le rendu détaillé sera ajouté à l'étape suivante.",
"instanceShell.filesShell.viewerEmpty": "Aucun fichier sélectionné.",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View File

@@ -86,12 +86,30 @@ export const instanceMessages = {
"instanceShell.empty.description": "メッセージを表示するにはセッションを選択してください",
"instanceShell.rightPanel.title": "ステータスパネル",
"instanceShell.rightPanel.tabs.changes": "変更",
"instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.plan": "計画",
"instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル",
"instanceShell.rightPanel.sections.mcp": "MCP サーバー",
"instanceShell.rightPanel.sections.lsp": "LSP サーバー",
"instanceShell.rightPanel.sections.plugins": "プラグイン",
"instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。",
"instanceShell.sessionChanges.loading": "変更を取得中...",
"instanceShell.sessionChanges.empty": "まだ変更はありません。",
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
"instanceShell.sessionChanges.actions.show": "変更を表示",
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
"instanceShell.filesShell.viewerTitle": "変更ビューア",
"instanceShell.filesShell.viewerPlaceholder": "詳細な変更表示は次のステップで追加します。",
"instanceShell.filesShell.viewerEmpty": "ファイルが選択されていません。",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",

View File

@@ -86,12 +86,30 @@ export const instanceMessages = {
"instanceShell.empty.description": "Выберите сессию, чтобы просмотреть сообщения",
"instanceShell.rightPanel.title": "Панель состояния",
"instanceShell.rightPanel.tabs.changes": "Изменения",
"instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.plan": "План",
"instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell",
"instanceShell.rightPanel.sections.mcp": "MCP-серверы",
"instanceShell.rightPanel.sections.lsp": "LSP-серверы",
"instanceShell.rightPanel.sections.plugins": "Плагины",
"instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.",
"instanceShell.sessionChanges.loading": "Загрузка изменений...",
"instanceShell.sessionChanges.empty": "Пока нет изменений.",
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
"instanceShell.sessionChanges.actions.show": "Показать изменения",
"instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
"instanceShell.filesShell.viewerTitle": "Просмотр изменений",
"instanceShell.filesShell.viewerPlaceholder": "Подробный рендер изменений будет добавлен на следующем этапе.",
"instanceShell.filesShell.viewerEmpty": "Файл не выбран.",
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.",

View File

@@ -86,12 +86,30 @@ export const instanceMessages = {
"instanceShell.empty.description": "选择会话以查看消息",
"instanceShell.rightPanel.title": "状态面板",
"instanceShell.rightPanel.tabs.changes": "更改",
"instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.plan": "计划",
"instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell",
"instanceShell.rightPanel.sections.mcp": "MCP 服务器",
"instanceShell.rightPanel.sections.lsp": "LSP 服务器",
"instanceShell.rightPanel.sections.plugins": "插件",
"instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。",
"instanceShell.sessionChanges.loading": "正在获取会话更改...",
"instanceShell.sessionChanges.empty": "暂无会话更改。",
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
"instanceShell.sessionChanges.actions.show": "显示更改",
"instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",
"instanceShell.filesShell.viewerTitle": "更改查看器",
"instanceShell.filesShell.viewerPlaceholder": "详细更改渲染将在下一步中添加。",
"instanceShell.filesShell.viewerEmpty": "未选择文件。",
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。",

View File

@@ -0,0 +1,70 @@
type MonacoApi = any
let cachedLanguageMaps:
| {
fileNameToId: Map<string, string>
extToId: Map<string, string>
}
| null = null
function buildLanguageMaps(monaco: MonacoApi) {
if (cachedLanguageMaps) return cachedLanguageMaps
const fileNameToId = new Map<string, string>()
const extToId = new Map<string, string>()
const languages = typeof monaco?.languages?.getLanguages === "function" ? monaco.languages.getLanguages() : []
if (Array.isArray(languages)) {
for (const lang of languages) {
const id = typeof lang?.id === "string" ? lang.id : null
if (!id) continue
const filenames = Array.isArray(lang?.filenames) ? lang.filenames : []
for (const name of filenames) {
if (typeof name !== "string") continue
if (!fileNameToId.has(name)) fileNameToId.set(name, id)
}
const extensions = Array.isArray(lang?.extensions) ? lang.extensions : []
for (const ext of extensions) {
if (typeof ext !== "string") continue
// Monaco uses leading dots for extensions (e.g. ".ts").
if (!extToId.has(ext)) extToId.set(ext, id)
}
}
}
cachedLanguageMaps = { fileNameToId, extToId }
return cachedLanguageMaps
}
function overrideLanguageId(fileName: string): string | null {
// Git-style ignore/config files: treat as shell-like.
if (fileName === ".gitignore" || fileName === ".gitattributes" || fileName === ".gitmodules") return "shell"
// Monaco doesn't ship a dedicated Makefile tokenizer in our baseline.
if (fileName === "Makefile" || fileName.startsWith("Makefile.")) return "shell"
return null
}
export function inferMonacoLanguageId(monaco: MonacoApi, path: string | undefined | null): string {
const raw = String(path || "").trim()
const fileName = raw.split("/").pop() || raw
const override = overrideLanguageId(fileName)
if (override) return override
const maps = buildLanguageMaps(monaco)
const byName = maps.fileNameToId.get(fileName)
if (byName) return byName
const dot = fileName.lastIndexOf(".")
if (dot > 0) {
const ext = fileName.slice(dot)
const byExt = maps.extToId.get(ext)
if (byExt) return byExt
}
return "plaintext"
}

View File

@@ -0,0 +1,53 @@
type MonacoApi = any
type CachedModel = {
model: any
}
const MAX_MODELS = 5
// LRU map: newest at the end.
const models = new Map<string, CachedModel>()
function touch(key: string, entry: CachedModel) {
models.delete(key)
models.set(key, entry)
}
function evictIfNeeded() {
while (models.size > MAX_MODELS) {
const oldestKey = models.keys().next().value as string | undefined
if (!oldestKey) return
const entry = models.get(oldestKey)
models.delete(oldestKey)
try {
entry?.model.dispose()
} catch {
// ignore
}
}
}
export function getOrCreateTextModel(params: {
monaco: MonacoApi
cacheKey: string
value: string
languageId: string
}): any {
const existing = models.get(params.cacheKey)
if (existing) {
touch(params.cacheKey, existing)
if (existing.model.getValue() !== params.value) {
existing.model.setValue(params.value)
}
return existing.model
}
const uri = params.monaco.Uri.parse(`opencode://model/${encodeURIComponent(params.cacheKey)}`)
// Create as plaintext. We'll set the final language after its contribution is loaded.
const model = params.monaco.editor.createModel(params.value, "plaintext", uri)
const entry = { model }
models.set(params.cacheKey, entry)
evictIfNeeded()
return model
}

View File

@@ -0,0 +1,207 @@
type RequireFn = (deps: string[], callback: (...args: any[]) => void, errback?: (err: any) => void) => void
type MonacoApi = any
const MONACO_VERSION = "0.52.2"
const CDN_VS_ROOT = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}/min/vs`
const LOCAL_VS_ROOT = "/monaco/vs"
let monacoPromise: Promise<MonacoApi> | null = null
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const id = setTimeout(() => reject(new Error("timeout")), ms)
promise
.then((value) => {
clearTimeout(id)
resolve(value)
})
.catch((err) => {
clearTimeout(id)
reject(err)
})
})
}
async function canReachCdn(): Promise<boolean> {
if (typeof fetch === "undefined") return false
try {
const controller = new AbortController()
const task = fetch(`${CDN_VS_ROOT}/loader.js`, { method: "HEAD", signal: controller.signal })
const response = await withTimeout(task, 1200)
controller.abort()
return response.ok
} catch {
return false
}
}
function ensureLoaderScript(): Promise<void> {
if (typeof document === "undefined") return Promise.resolve()
const existing = document.querySelector('script[data-monaco-loader="true"]')
if (existing) return Promise.resolve()
return new Promise((resolve, reject) => {
const script = document.createElement("script")
script.dataset.monacoLoader = "true"
script.src = `${LOCAL_VS_ROOT}/loader.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error("Failed to load Monaco AMD loader"))
document.head.appendChild(script)
})
}
function ensureEditorCss(): void {
if (typeof document === "undefined") return
const existing = document.querySelector('link[data-monaco-editor-css="true"]')
if (existing) return
// Some environments don't reliably load `vs/css!` plugin resources.
// Loading the core stylesheet explicitly keeps Monaco visible.
const link = document.createElement("link")
link.rel = "stylesheet"
link.href = `${LOCAL_VS_ROOT}/editor/editor.main.css`
;(link as any).dataset.monacoEditorCss = "true"
document.head.appendChild(link)
}
function configureWorkers() {
const globalAny = globalThis as any
const prevEnv = globalAny.MonacoEnvironment ?? {}
// Monaco's AMD build no longer ships `editor.worker.js` (and language workers are
// `jsonWorker.js`, `cssWorker.js`, etc). The robust approach is to always boot
// `vs/base/worker/workerMain.js` and let it `require(...)` the requested module.
//
// Important: `workerMain.js` expects `MonacoEnvironment.baseUrl` to be the
// directory containing the `vs/` folder (so `/monaco/`, not `/monaco/vs`).
// Use a static worker bootstrap script rather than a `data:` URL.
// This avoids CSP issues and makes worker requests visible in DevTools.
const workerUrl = "/monaco.worker.js"
globalAny.MonacoEnvironment = {
...prevEnv,
getWorkerUrl(_moduleId: string, _label: string) {
return workerUrl
},
}
}
function getRequire(): RequireFn {
const req = (globalThis as any).require as RequireFn | undefined
if (!req) throw new Error("Monaco AMD loader is not available")
return req
}
function getRequireConfig(): ((config: any) => void) {
const req = getRequire() as any
const cfg = req.config as ((config: any) => void) | undefined
if (!cfg) throw new Error("require.config is not available")
return cfg
}
function requireAsync(deps: string[]): Promise<any[]> {
const req = getRequire()
return new Promise((resolve, reject) => {
req(deps, (...args: any[]) => resolve(args), (err: any) => reject(err))
})
}
function getContributionModuleId(languageId: string): string | null {
const id = String(languageId || "plaintext")
if (!id || id === "plaintext") return null
// Rich contributions
if (id === "typescript" || id === "javascript") return "vs/language/typescript/monaco.contribution"
if (id === "json") return "vs/language/json/monaco.contribution"
if (id === "css" || id === "scss" || id === "less") return "vs/language/css/monaco.contribution"
if (id === "html") return "vs/language/html/monaco.contribution"
// Basic tokenizers
// Monaco's `min/vs/basic-languages/<id>/` ships `<id>.js` (no `*.contribution.js`).
// Loading the tokenizer module is enough; it registers itself with the language service.
if (id === "toml") return "vs/basic-languages/toml/toml"
return `vs/basic-languages/${id}/${id}`
}
const loadedContributions = new Set<string>()
const pendingContributions = new Map<string, Promise<void>>()
export async function ensureMonacoLanguageLoaded(languageId: string): Promise<void> {
const moduleId = getContributionModuleId(languageId)
if (!moduleId) return
if (loadedContributions.has(moduleId)) return
const pending = pendingContributions.get(moduleId)
if (pending) return pending
const task = (async () => {
let loaded = false
try {
await requireAsync([moduleId])
loaded = true
} catch {
// ignore
} finally {
if (loaded) loadedContributions.add(moduleId)
pendingContributions.delete(moduleId)
}
})()
pendingContributions.set(moduleId, task)
return task
}
export async function loadMonaco(): Promise<MonacoApi> {
if (monacoPromise) return monacoPromise
monacoPromise = (async () => {
await ensureLoaderScript()
configureWorkers()
ensureEditorCss()
const online = await canReachCdn()
const requireConfig = getRequireConfig()
const paths: Record<string, string> = {
vs: LOCAL_VS_ROOT,
}
if (online) {
paths["vs/basic-languages"] = `${CDN_VS_ROOT}/basic-languages`
paths["vs/language"] = `${CDN_VS_ROOT}/language`
// Baseline languages should remain available offline too.
paths["vs/basic-languages/python"] = `${LOCAL_VS_ROOT}/basic-languages/python`
paths["vs/basic-languages/markdown"] = `${LOCAL_VS_ROOT}/basic-languages/markdown`
paths["vs/basic-languages/cpp"] = `${LOCAL_VS_ROOT}/basic-languages/cpp`
paths["vs/basic-languages/kotlin"] = `${LOCAL_VS_ROOT}/basic-languages/kotlin`
paths["vs/language/typescript"] = `${LOCAL_VS_ROOT}/language/typescript`
paths["vs/language/html"] = `${LOCAL_VS_ROOT}/language/html`
paths["vs/language/json"] = `${LOCAL_VS_ROOT}/language/json`
paths["vs/language/css"] = `${LOCAL_VS_ROOT}/language/css`
}
requireConfig({
paths,
ignoreDuplicateModules: ["vs/editor/editor.main"],
})
// Load editor core.
const [monaco] = await requireAsync(["vs/editor/editor.main"])
// Load language metadata so we can infer language IDs from paths.
// (This is small and should remain local for offline support.)
// Note: In Monaco 0.52.x, `vs/basic-languages/monaco.contribution` is bundled
// into `vs/editor/editor.main` already. Older builds had additional
// `vs/basic-languages/_.contribution` metadata, but that module isn't present
// in the current AMD bundle; attempting to load it can trigger a hard
// `Unexpected token '<'` if the server falls back to `index.html`.
await requireAsync(["vs/basic-languages/monaco.contribution"]).catch(() => [])
return (globalThis as any).monaco ?? monaco
})()
return monacoPromise
}

View File

@@ -9,6 +9,7 @@ import type {
EventLspUpdated,
EventSessionCompacted,
EventSessionDiff,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
@@ -59,8 +60,10 @@ type SSEEvent =
| MessagePartRemovedEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionDiff
| EventSessionError
| EventSessionIdle
| EventSessionStatus
| { type: "permission.updated" | "permission.asked"; properties?: any }
| { type: "permission.replied"; properties?: any }
| { type: "question.asked"; properties?: any }
@@ -139,6 +142,9 @@ class SSEManager {
case "session.status":
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
break
case "session.diff":
this.onSessionDiff?.(instanceId, event as EventSessionDiff)
break
case "permission.updated":
case "permission.asked":
this.onPermissionUpdated?.(instanceId, event as any)
@@ -185,6 +191,7 @@ class SSEManager {
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void
onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => void
onQuestionAsked?: (instanceId: string, event: any) => void

View File

@@ -0,0 +1,86 @@
import { applyPatch, parsePatch } from "diff"
type ParsedPatchIndex = ReturnType<typeof parsePatch>[number]
type ParsedHunk = ParsedPatchIndex["hunks"][number]
type SdkPatch = {
oldFileName: string
newFileName: string
oldHeader?: string
newHeader?: string
hunks: Array<{
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: Array<string>
}>
index?: string
}
function invertPatchLine(line: string): string {
if (!line) return line
const op = line[0]
if (op === "+") return `-${line.slice(1)}`
if (op === "-") return `+${line.slice(1)}`
return line
}
function reverseParsedHunk(hunk: ParsedHunk): ParsedHunk {
return {
oldStart: hunk.newStart,
oldLines: hunk.newLines,
newStart: hunk.oldStart,
newLines: hunk.oldLines,
lines: hunk.lines.map(invertPatchLine),
linedelimiters: Array.isArray((hunk as any).linedelimiters) ? (hunk as any).linedelimiters : [],
} as ParsedHunk
}
function reverseParsedIndex(index: ParsedPatchIndex): ParsedPatchIndex {
const hunks = Array.isArray(index.hunks) ? index.hunks : []
return {
...index,
oldFileName: (index as any).newFileName,
newFileName: (index as any).oldFileName,
oldHeader: (index as any).newHeader,
newHeader: (index as any).oldHeader,
hunks: hunks.map(reverseParsedHunk),
} as ParsedPatchIndex
}
export function buildUnifiedDiffFromSdkPatch(patch: SdkPatch): string {
const oldName = patch.oldFileName || "a/file"
const newName = patch.newFileName || "b/file"
const oldHeader = patch.oldHeader ? `\t${patch.oldHeader}` : ""
const newHeader = patch.newHeader ? `\t${patch.newHeader}` : ""
const lines: string[] = []
if (patch.index) {
// jsdiff can parse arbitrary metadata lines before file headers.
lines.push(`Index: ${patch.index}`)
}
lines.push(`--- ${oldName}${oldHeader}`)
lines.push(`+++ ${newName}${newHeader}`)
for (const hunk of patch.hunks || []) {
const oldRange = hunk.oldLines === 1 ? `${hunk.oldStart}` : `${hunk.oldStart},${hunk.oldLines}`
const newRange = hunk.newLines === 1 ? `${hunk.newStart}` : `${hunk.newStart},${hunk.newLines}`
lines.push(`@@ -${oldRange} +${newRange} @@`)
for (const line of hunk.lines || []) {
lines.push(line)
}
}
return `${lines.join("\n")}\n`
}
export function tryReverseApplyUnifiedDiff(afterText: string, diffText: string): string | null {
const normalized = (diffText ?? "").trim()
if (!normalized) return null
const parsed = parsePatch(diffText)
if (!Array.isArray(parsed) || parsed.length === 0) return null
const reversed = reverseParsedIndex(parsed[0])
const result = applyPatch(afterText ?? "", reversed)
return typeof result === "string" ? result : null
}

View File

@@ -0,0 +1,7 @@
// Monaco web worker bootstrap.
//
// `workerMain.js` expects `MonacoEnvironment.baseUrl` to be the directory that
// contains the `vs/` folder (so `/monaco/`, not `/monaco/vs`).
self.MonacoEnvironment = { baseUrl: "/monaco/" }
importScripts("/monaco/vs/base/worker/workerMain.js")

View File

@@ -1,5 +1,6 @@
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
@@ -19,6 +20,7 @@ import {
setSessionInfoByInstance,
setSessions,
sessions,
withSession,
loading,
setLoading,
cleanupBlankSessions,
@@ -42,6 +44,49 @@ import {
const log = getLogger("api")
const pendingSessionDiffFetches = new Map<string, Promise<void>>()
async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise<void> {
if (!instanceId || !sessionId) return
const key = `${instanceId}:${sessionId}`
if (!force) {
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing?.diff !== undefined) return
const pending = pendingSessionDiffFetches.get(key)
if (pending) return pending
}
const promise = (async () => {
const instance = instances().get(instanceId)
if (!instance?.client) return
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
try {
const diffs = await requestData<FileDiff[]>(
client.session.diff({ sessionID: sessionId }),
"session.diff",
)
if (!Array.isArray(diffs)) {
return
}
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
} catch (error) {
log.warn("Failed to fetch session diff", { instanceId, sessionId, error })
}
})()
pendingSessionDiffFetches.set(key, promise)
void promise.finally(() => pendingSessionDiffFetches.delete(key))
return promise
}
interface SessionForkResponse {
id: string
title?: string
@@ -570,6 +615,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
throw new Error("Session not found")
}
// Fetch session-level diffs in the background once the session is opened.
void loadSessionDiff(instanceId, sessionId).catch((error) => {
log.warn("Failed to load session diff", { instanceId, sessionId, error })
})
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId) || new Set()

View File

@@ -7,6 +7,7 @@ import type {
} from "../types/message"
import type {
EventSessionCompacted,
EventSessionDiff,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
@@ -428,6 +429,31 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
}
}
function handleSessionDiff(instanceId: string, event: EventSessionDiff): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
const diffs = event.properties?.diff
if (!Array.isArray(diffs)) return
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing) {
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
return
}
// A diff event can arrive before we have hydrated the session list.
// Best-effort: fetch the session record so the diff has somewhere to live.
void (async () => {
await fetchSessionInfo(instanceId, sessionId, (event as any)?.directory)
withSession(instanceId, sessionId, (session) => {
session.diff = diffs
})
})().catch((error) => log.warn("Failed to hydrate session for diff event", { instanceId, sessionId, error }))
}
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
@@ -605,6 +631,7 @@ export {
handleQuestionAsked,
handleQuestionAnswered,
handleSessionCompacted,
handleSessionDiff,
handleSessionError,
handleSessionIdle,
handleSessionStatus,

View File

@@ -64,6 +64,7 @@ import {
handleQuestionAnswered,
handleQuestionAsked,
handleSessionCompacted,
handleSessionDiff,
handleSessionError,
handleSessionIdle,
handleSessionStatus,
@@ -77,6 +78,7 @@ sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionDiff = handleSessionDiff
sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle
sseManager.onSessionStatus = handleSessionStatus

View File

@@ -4,15 +4,28 @@
}
.message-item-header {
@apply flex flex-col gap-0.5;
}
.message-item-header-row {
@apply w-full;
}
.message-item-header-row--top {
@apply flex justify-between items-start gap-2.5;
}
.message-item-header-row--bottom {
@apply flex items-start;
}
.message-speaker {
@apply flex flex-col gap-0.5 text-xs;
}
.message-speaker-label {
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
}
.message-speaker-label[data-role="user"] {
@@ -298,11 +311,25 @@
gap: 0;
}
.message-reasoning-header {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0.5rem;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.message-reasoning-header:hover {
background-color: var(--surface-hover);
}
.message-reasoning-toggle {
width: 100%;
flex: 1 1 auto;
min-width: 0;
width: auto;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.65rem;
background: none;
border: none;
@@ -314,13 +341,20 @@
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.message-reasoning-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.6rem 0.25rem 0;
}
.message-reasoning-toggle:hover {
background-color: var(--surface-hover);
background-color: transparent;
}
.message-reasoning-toggle:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-primary);
box-shadow: 0 0 0 1px var(--border-base);
}
.message-reasoning-label {

View File

@@ -3,6 +3,7 @@
@import "./panels/modal.css";
@import "./panels/panel-shell.css";
@import "./panels/session-layout.css";
@import "./panels/right-panel.css";
.tab-bar-instance {
@@ -32,11 +33,13 @@
.tab-active {
background-color: var(--tab-active-bg);
color: var(--tab-active-text);
border-bottom: 2px solid var(--accent-primary);
}
.tab-inactive {
background-color: var(--tab-inactive-bg);
color: var(--tab-inactive-text);
border-bottom: 2px solid var(--tab-active-bg);
}
.tab-inactive:hover {

View File

@@ -0,0 +1,485 @@
/* Right Panel / Drawer specific styles */
/* Right panel tab bar - browser-style tabs */
.right-panel-tab-bar {
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
position: relative;
}
.right-panel-tab-bar::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 1px;
background-color: var(--border-base);
z-index: 0;
}
.right-panel-tab-bar .tab-container {
@apply flex items-center justify-between gap-1 px-2 pt-2 pb-0;
}
/* Keep the shortcuts (close/pin) fixed; only the tabs should scroll. */
.right-panel-tab-bar .tab-scroll {
@apply flex-1;
width: auto;
}
/* Shortcuts on the left side - match left drawer icon colors */
.tab-strip-shortcuts {
@apply flex items-center gap-1 flex-shrink-0;
color: var(--text-primary);
}
/* Browser-style tabs - using INSTANCE tab color scheme */
.right-panel-tab {
@apply inline-flex items-center gap-2 px-4 py-2 text-xs font-medium transition-all duration-150 relative;
font-family: var(--font-family-sans);
outline: none;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
margin-right: 2px;
z-index: 1;
}
.right-panel-tab:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.right-panel-tab-active {
background-color: var(--tab-active-bg);
border-color: transparent;
border-bottom: 2px solid var(--accent-primary);
color: var(--tab-active-text);
z-index: 2;
}
.right-panel-tab-inactive {
background-color: var(--tab-inactive-bg);
color: var(--tab-inactive-text);
border-color: transparent;
border-bottom: 2px solid var(--tab-active-bg);
}
.right-panel-tab-inactive:hover {
background-color: var(--tab-inactive-hover-bg);
color: var(--text-secondary);
border-color: var(--border-base);
border-bottom-color: transparent;
}
/* Files tab layout */
.files-tab-container {
@apply flex flex-col h-full min-h-0;
}
/* Split view (file list + viewer) */
.files-split {
display: grid;
grid-template-columns: var(--files-pane-width, 320px) 10px minmax(0, 1fr);
min-height: 0;
flex: 1 1 auto;
}
.file-split-handle {
cursor: col-resize;
background-color: transparent;
border-left: 1px solid var(--border-base);
border-right: 1px solid var(--border-base);
user-select: none;
touch-action: none;
}
.file-split-handle:hover {
background-color: var(--surface-hover);
}
.files-tab-header {
@apply flex items-center justify-between gap-2 px-3 py-2 border-b;
border-color: var(--border-base);
}
.files-tab-header-row {
@apply flex items-center gap-2 w-full min-w-0;
}
.files-toggle-button {
@apply text-[11px] px-2 py-1 border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-secondary);
}
.files-toggle-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.files-header-icon-button {
@apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-secondary);
}
.files-header-icon-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.files-header-icon-button:disabled {
opacity: 0.55;
cursor: default;
}
.files-tab-body {
@apply flex flex-col flex-1 min-h-0;
position: relative;
isolation: isolate;
}
.file-list-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background-color: var(--surface-secondary);
border-left: 1px solid var(--border-base);
/* Monaco uses layered positioned elements; keep overlay well above it. */
z-index: 200;
}
/* Phone overlay: header removed; use main header toggle. */
.files-tab-stats {
@apply flex items-center gap-3 text-[11px];
color: var(--text-muted);
}
.files-tab-selected-path {
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
color: var(--text-primary);
text-overflow: ellipsis;
/* Truncate from the start; keep filename visible. */
direction: rtl;
text-align: left;
unicode-bidi: isolate;
}
.files-tab-selected-path .file-path-text {
direction: ltr;
unicode-bidi: isolate;
}
.files-tab-stat {
@apply flex items-center gap-1.5;
}
.files-tab-stat-value {
@apply font-semibold;
}
.files-tab-stat-additions {
color: var(--session-status-idle-fg);
}
.files-tab-stat-deletions {
color: var(--session-status-working-fg);
}
/* File list panel */
.file-list-panel {
@apply flex flex-col min-h-0;
background-color: transparent;
}
.file-list-header {
@apply flex items-center justify-between gap-2 px-3 py-2 border-b;
border-color: var(--border-base);
background-color: transparent;
}
.file-list-title {
@apply text-[11px] font-semibold uppercase tracking-wide;
color: var(--text-muted);
}
.file-list-count {
@apply text-[10px] px-1.5 py-0.5 rounded-full;
background-color: var(--surface-base);
color: var(--text-muted);
border: 1px solid var(--border-base);
}
.file-list-scroll {
@apply flex-1 overflow-y-auto min-h-0;
}
.file-list-item {
@apply px-3 py-2.5 border-b cursor-pointer transition-all duration-150;
border-color: var(--border-base);
background-color: transparent;
}
.file-list-item:last-child {
@apply border-b-0;
}
.file-list-item:hover {
background-color: var(--surface-hover);
}
.file-list-item-active {
background-color: var(--surface-base);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
.file-list-item-content {
@apply flex items-center justify-between gap-3;
}
.file-list-item-path {
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
color: var(--text-primary);
text-overflow: ellipsis;
/* Truncate from the start; keep filename visible. */
direction: rtl;
text-align: left;
unicode-bidi: isolate;
}
.file-list-item-path .file-path-text {
direction: ltr;
unicode-bidi: isolate;
}
.file-list-item-stats {
@apply flex items-center gap-2 text-[11px] flex-shrink-0;
}
.file-list-item-additions {
color: var(--session-status-idle-fg);
}
.file-list-item-deletions {
color: var(--session-status-working-fg);
}
/* File viewer panel */
.file-viewer-panel {
@apply flex flex-col min-h-0;
background-color: transparent;
}
.file-viewer-header {
@apply flex items-center gap-2 px-3 py-2 border-b;
border-color: var(--border-base);
background-color: transparent;
}
.file-viewer-toolbar {
@apply ml-auto flex items-center gap-1;
}
.file-viewer-toolbar-button {
@apply text-[11px] px-2 py-1 rounded border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-secondary);
}
.file-viewer-toolbar-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.file-viewer-toolbar-button.active {
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
.file-viewer-title {
@apply text-[11px] font-semibold uppercase tracking-wide;
color: var(--text-muted);
}
.file-viewer-content {
@apply flex-1 p-4 overflow-auto min-h-0;
}
.file-viewer-content--monaco {
@apply flex-1 overflow-hidden min-h-0;
padding: 0;
}
.monaco-viewer {
width: 100%;
height: 100%;
}
.file-viewer-empty {
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
color: var(--text-muted);
}
.file-viewer-empty-icon {
@apply w-8 h-8 opacity-40;
color: var(--text-muted);
}
.file-viewer-empty-text {
@apply text-xs;
}
.file-viewer-placeholder {
@apply text-xs leading-relaxed;
color: var(--text-muted);
}
.file-viewer-selected-file {
@apply flex flex-col gap-2;
}
.file-viewer-file-name {
@apply text-xs font-mono px-2 py-1.5 rounded border;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
word-break: break-all;
}
/* Mobile file selector */
.mobile-file-selector {
@apply rounded-lg border flex flex-col gap-3 p-3;
background-color: var(--surface-secondary);
border-color: var(--border-base);
}
.mobile-file-selector-label {
@apply text-[11px] font-semibold uppercase tracking-wide;
color: var(--text-muted);
}
.mobile-file-selector-trigger {
@apply w-full;
}
.mobile-file-viewer {
@apply rounded-lg border flex flex-col min-h-[220px];
background-color: var(--surface-secondary);
border-color: var(--border-base);
}
/* Status tab layout */
.status-tab-container {
@apply flex flex-col h-full min-h-0;
}
.status-tab-context-panel {
@apply border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
/* Accordion improvements for right panel */
.right-panel-accordion {
@apply flex flex-col;
}
.right-panel-accordion-item {
@apply border-b last:border-b-0;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.right-panel-accordion-trigger {
@apply w-full flex items-center justify-between gap-3 px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150;
color: var(--text-secondary);
background-color: transparent;
}
.right-panel-accordion-trigger:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.right-panel-accordion-chevron {
@apply h-4 w-4 transition-transform duration-200;
color: var(--text-muted);
}
.right-panel-accordion-chevron-expanded {
transform: rotate(180deg);
}
.right-panel-accordion-content {
@apply w-full px-3 pb-3 text-sm;
color: var(--text-primary);
min-height: 0;
}
.right-panel-accordion-content [data-accordion-content] {
min-height: 0;
}
/* Background process cards in status panel */
.status-process-card {
@apply rounded-lg border flex flex-col gap-2 p-3 transition-all duration-150;
background-color: var(--surface-base);
border-color: var(--border-base);
}
.status-process-card:hover {
border-color: var(--border-strong);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.status-process-header {
@apply flex flex-col gap-1;
}
.status-process-title {
@apply text-xs font-semibold;
color: var(--text-primary);
}
.status-process-meta {
@apply flex flex-wrap gap-2 text-[11px];
color: var(--text-muted);
}
.status-process-actions {
@apply grid grid-cols-3 gap-2;
}
/* Empty states */
.right-panel-empty {
@apply flex flex-col items-center justify-center text-center gap-2;
color: var(--text-muted);
}
.right-panel-empty--left {
@apply items-start justify-start text-left w-full;
}
.right-panel-empty-text {
@apply text-xs;
}
/* Dark mode adjustments */
[data-theme="dark"] .right-panel-tab-active {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .file-list-item-active {
box-shadow: inset 0 0 0 1px var(--accent-primary), 0 0 0 1px var(--surface-secondary);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .right-panel-tab-active {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}

View File

@@ -58,11 +58,13 @@
.tab-active {
background-color: var(--tab-active-bg);
color: var(--tab-active-text);
border-bottom: 2px solid var(--accent-primary);
}
.tab-inactive {
background-color: var(--tab-inactive-bg);
color: var(--tab-inactive-text);
border-bottom: 2px solid var(--tab-active-bg);
}
.tab-inactive:hover {

View File

@@ -153,6 +153,16 @@
@apply opacity-50;
}
/* Truncate from the start (keeps end visible; good for paths) */
.truncate-start {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
/* Prevent iOS Safari auto-zoom on text input focus */
@media (pointer: coarse) {
input[type="text"],

5
packages/ui/src/types/diff.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "diff" {
// Minimal types for the jsdiff package used in git-change reconstruction.
export function parsePatch(input: string, options?: any): any[]
export function applyPatch(source: string, patch: any, options?: any): string | false
}

View File

@@ -5,6 +5,7 @@ import type {
Model as SDKModel,
} from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use
export type {
@@ -39,6 +40,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
}
// Adapter function to convert SDK Session to client Session

View File

@@ -3,6 +3,7 @@ import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
import { VitePWA } from "vite-plugin-pwa"
import { resolve } from "path"
import { copyMonacoPublicAssets } from "./scripts/monaco-public-assets.js"
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
const uiVersion = uiPackageJson.version ?? "0.0.0"
@@ -11,6 +12,31 @@ export default defineConfig({
root: "./src/renderer",
plugins: [
solid(),
{
name: "prepare-monaco-public-assets",
// Ensure Monaco's AMD assets exist in `root/public` for both dev server and builds.
// These files are gitignored and generated on demand.
configureServer(server) {
copyMonacoPublicAssets({
uiRendererRoot: resolve(__dirname, "src/renderer"),
warn: (msg) => server.config.logger.warn(msg),
sourceRoots: [
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
resolve(__dirname, "node_modules/monaco-editor/min/vs"),
],
})
},
buildStart() {
copyMonacoPublicAssets({
uiRendererRoot: resolve(__dirname, "src/renderer"),
warn: (msg) => this.warn(msg),
sourceRoots: [
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
resolve(__dirname, "node_modules/monaco-editor/min/vs"),
],
})
},
},
{
name: "emit-ui-version",
generateBundle() {
@@ -51,14 +77,23 @@ export default defineConfig({
theme_color: "#1a1a1a",
},
workbox: {
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
navigateFallback: null,
// Only precache static assets (avoid caching HTML documents / routes).
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
globIgnores: ["**/*.html"],
// Only cache static UI assets; never cache API traffic.
runtimeCaching: [
{
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
// This is a build-time limit for the precache manifest, not a hard runtime cap.
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
navigateFallback: null,
// Only precache static assets (avoid caching HTML documents / routes).
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
// Monaco assets can be large; cache them at runtime instead.
globIgnores: [
"**/*.html",
"**/assets/*worker-*.js",
"**/assets/editor.api-*.js",
"**/monaco/vs/**/*",
],
// Only cache static UI assets; never cache API traffic.
runtimeCaching: [
{
urlPattern: ({ url, request }) => {
if (url.pathname.startsWith("/api/")) return false
if (request.destination === "document") return false