From 2ca118db59f9beda1a30ae7fe8b2ae67e0738133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Mon, 15 Dec 2025 15:32:06 +0100 Subject: [PATCH 001/104] docs: Fix Wakatime repository link in ecosystem.mdx (#5552) Co-authored-by: Github Action --- flake.lock | 6 +++--- packages/web/src/content/docs/ecosystem.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 58344d82c..0d77a07fd 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1765425892, - "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", + "lastModified": 1765644376, + "narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", + "rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe", "type": "github" }, "original": { diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index c62f12cb3..6a3119549 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -27,7 +27,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | | [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | | [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | From 274b86b19bcd2b6d27fb91212ebe29a0b1b3ba57 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:47:19 -0500 Subject: [PATCH 002/104] ci: fix AppImage build hanging by using portable appimage format - Add appimage target back to tauri.conf.json - Force reinstall tauri-cli from feat/truly-portable-appimage branch - Add 10 minute timeout to prevent indefinite hangs - Add logging to verify correct tauri-cli version is installed This fixes the issue where AppImage builds would hang at 'Running input plugin: gtk' by using the new portable appimage format that bypasses linuxdeploy entirely. --- .github/workflows/publish.yml | 7 ++++++- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c44efe1b..3120a02f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -166,10 +166,15 @@ jobs: GH_TOKEN: ${{ github.token }} # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage + - name: Install tauri-cli from portable appimage branch if: contains(matrix.settings.host, 'ubuntu') + run: | + cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + echo "Installed tauri-cli version:" + cargo tauri --version - name: Build and upload artifacts + timeout-minutes: 10 uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 6813a218b..e2d1f239a 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "nsis"], + "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, From b0f77da56c0238667c897d084f4c51eab263df6f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:58:23 -0500 Subject: [PATCH 003/104] core: reorganize agent configuration to separate primary agents (build, plan) from subagents --- .github/workflows/publish.yml | 2 +- packages/opencode/src/agent/agent.ts | 36 +++++++++++++------------- packages/opencode/src/config/config.ts | 6 +++++ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3120a02f3..4e35af4e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-4vcpu-ubuntu-2204 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ef007df13..a2ff825d9 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -107,6 +107,24 @@ export namespace Agent { ) const result: Record = { + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, + permission: agentPermission, + mode: "primary", + native: true, + }, + plan: { + name: "plan", + options: {}, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + native: true, + }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -149,14 +167,6 @@ export namespace Agent { options: {}, permission: agentPermission, }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - native: true, - }, title: { name: "title", mode: "primary", @@ -177,16 +187,6 @@ export namespace Agent { prompt: PROMPT_SUMMARY, tools: {}, }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, - }, - mode: "primary", - native: true, - }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 333e19848..d0fb12c58 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -668,10 +668,16 @@ export namespace Config { .describe("@deprecated Use `agent` field instead."), agent: z .object({ + // primary plan: Agent.optional(), build: Agent.optional(), + // subagent general: Agent.optional(), explore: Agent.optional(), + // specialized + title: Agent.optional(), + summary: Agent.optional(), + compaction: Agent.optional(), }) .catchall(Agent) .optional() From f492122d590030d20ae8445596f77ebfc00eb9ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 14:59:05 +0000 Subject: [PATCH 004/104] chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ packages/sdk/openapi.json | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c466e78dc..a92709561 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1413,6 +1413,9 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig [key: string]: AgentConfig | undefined } /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71f1df312..5a978c69c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7992,6 +7992,15 @@ }, "explore": { "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" } }, "additionalProperties": { From d2ce368a3fc15a740da097385990b60efb2b6e68 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 10:14:18 -0500 Subject: [PATCH 005/104] ci: update publish workflow concurrency to include version inputs and upgrade ubuntu runner to 24.04 --- .github/workflows/publish.yml | 4 ++-- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e35af4e2..4e3df621a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} permissions: id-token: write @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2204 + - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index e2d1f239a..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], + "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, From 56dde2cc835f509f77cbd800d080d6dbb2b8edc6 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 15 Dec 2025 16:01:15 +0000 Subject: [PATCH 006/104] release: v1.0.154 --- bun.lock | 30 +++++++++++++------------- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/tauri/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index c709bd83b..d7e87dd5e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -199,7 +199,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -215,7 +215,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.153", + "version": "1.0.154", "bin": { "opencode": "./bin/opencode", }, @@ -307,7 +307,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -327,7 +327,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -338,7 +338,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -351,7 +351,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -376,7 +376,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -411,7 +411,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "zod": "catalog:", }, @@ -422,7 +422,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4fcaff701..e3d371d3d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f68f70436..329ad92f9 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 53a41670d..b1aa5e080 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index a03e3843b..e9de718de 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0f5afeaa9..90f00980d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "description": "", "type": "module", "exports": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d0589587b..481b893ed 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 52f948655..124bb4433 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.153" +version = "1.0.154" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c2ee790ce..9320bedc5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bbeb9ae0a..7a63b3532 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.153", + "version": "1.0.154", "name": "opencode", "type": "module", "private": true, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4a7841908..509d00949 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a18040830..ac9e2f050 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 7a5c339e8..b38bad972 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 22fa35023..5cec6c0cd 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2632f6961..739953777 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/util/package.json b/packages/util/package.json index c77d99e7c..16149e204 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 9d028edbb..7c8ca723d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.153", + "version": "1.0.154", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6291f7177..753f8526f 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.153", + "version": "1.0.154", "publisher": "sst-dev", "repository": { "type": "git", From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:09:57 -0600 Subject: [PATCH 007/104] wip(desktop): progress --- packages/desktop/src/app.tsx | 45 ++-- .../desktop/src/components/prompt-input.tsx | 203 +++++++++----- packages/desktop/src/context/command.tsx | 255 ++++++++++++++++++ packages/desktop/src/pages/session.tsx | 117 ++++---- 4 files changed, 475 insertions(+), 145 deletions(-) create mode 100644 packages/desktop/src/context/command.tsx diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a49dac9aa..6414d0d49 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" @@ -40,27 +41,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6ab280fa6 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" interface PromptInputProps { class?: string @@ -53,6 +54,14 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string +} + export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() @@ -61,18 +70,21 @@ export const PromptInput: Component = (props) => { const session = useSession() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + slashFilter: string }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + slashFilter: "", }) const MAX_HISTORY = 100 @@ -157,17 +169,17 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef.addEventListener("paste", handlePaste) + editorRef?.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef.removeEventListener("paste", handlePaste) + editorRef?.removeEventListener("paste", handlePaste) }) createEffect(() => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,6 +194,53 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) + // Get slash commands from registered commands (only those with explicit slash trigger) + const slashCommands = createMemo(() => + command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + })), + ) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + // Since slash commands only trigger from start, just clear the input + editorRef.innerHTML = "" + session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("popover", null) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: () => { + const filter = store.slashFilter.toLowerCase() + return slashCommands().filter( + (cmd) => + cmd.trigger.toLowerCase().includes(filter) || + cmd.title.toLowerCase().includes(filter) || + cmd.description?.toLowerCase().includes(filter) || + false, + ) + }, + key: (x) => x?.id, + onSelect: handleSlashSelect, + }) + + // Update slash filter when store changes + createEffect(() => { + slashOnInput(store.slashFilter) + }) + createEffect( on( () => session.prompt.current(), @@ -256,11 +315,17 @@ export const PromptInput: Component = (props) => { const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + // Slash commands only trigger when / is at the start of input + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + setStore("slashFilter", slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -294,8 +359,6 @@ export const PromptInput: Component = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,7 +398,7 @@ export const PromptInput: Component = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => @@ -403,8 +466,13 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + // Handle popover navigation + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } @@ -441,8 +509,8 @@ export const PromptInput: Component = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + if (store.popover) { + setStore("popover", null) } else if (session.working()) { abort() } @@ -470,31 +538,9 @@ export const PromptInput: Component = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = prompt.filter((part) => part.type === "file") - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection @@ -519,7 +565,6 @@ export const PromptInput: Component = (props) => { session.layout.setActiveTab(undefined) session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty editorRef.innerHTML = "" session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) @@ -542,38 +587,66 @@ export const PromptInput: Component = (props) => { return (
- + {/* Popover for file mentions and slash commands */} +
- 0} fallback={
No matching files
}> - - {(i) => ( - + )} + +
+ + + 0} + fallback={
No matching commands
} + > + + {(cmd) => ( +
-
-
- - )} - - + + )} + + + +
void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + + props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x.id} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.clear() + option.onSelect?.("palette") + } + }} + > + {(option) => ( +
+
+ {option.title} + + {option.description} + +
+ + {formatKeybind(option.keybind!)} + +
+ )} +
+
+ ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (dialog.stack.length === 0) { + dialog.replace(() => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + // Check for command palette keybind (mod+shift+p) + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + // Check registered command keybinds + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index bef12fbd8..e3cac4842 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { useCommand } from "@/context/command" export default function Page() { const layout = useLayout() @@ -41,6 +42,7 @@ export default function Page() { const sync = useSync() const session = useSession() const dialog = useDialog() + const command = useCommand() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -48,16 +50,6 @@ export default function Page() { }) let inputRef!: HTMLDivElement - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - createEffect(() => { if (layout.terminal.opened()) { if (session.terminal.all().length === 0) { @@ -66,35 +58,54 @@ export default function Page() { } }) - const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - dialog.replace(() => ) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "t") { - event.preventDefault() - const currentTheme = localStorage.getItem("theme") ?? "oc-1" - const themes = ["oc-1", "oc-2-paper"] - const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - localStorage.setItem("theme", nextTheme) - document.documentElement.setAttribute("data-theme", nextTheme) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - if (event.shiftKey) { - session.terminal.new() - return - } - layout.terminal.toggle() - return - } + // Register commands for this page + command.register(() => [ + { + id: "file.open", + title: "Open file", + description: "Search and open a file", + category: "File", + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.replace(() => ), + }, + { + id: "theme.toggle", + title: "Toggle theme", + description: "Switch between themes", + category: "View", + keybind: "ctrl+t", + slash: "theme", + onSelect: () => { + const currentTheme = localStorage.getItem("theme") ?? "oc-1" + const themes = ["oc-1", "oc-2-paper"] + const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] + localStorage.setItem("theme", nextTheme) + document.documentElement.setAttribute("data-theme", nextTheme) + }, + }, + { + id: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal", + category: "View", + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => layout.terminal.toggle(), + }, + { + id: "terminal.new", + title: "New terminal", + description: "Create a new terminal tab", + category: "Terminal", + keybind: "ctrl+shift+`", + onSelect: () => session.terminal.new(), + }, + ]) + // Handle keyboard events that aren't commands + const handleKeyDown = (event: KeyboardEvent) => { + // Don't interfere with terminal // @ts-expect-error if (document.activeElement?.dataset?.component === "terminal") { return @@ -108,32 +119,20 @@ export default function Page() { return } - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - + // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + const resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) From d66d806700de9ad9fb0f3997a076ad23a815e6ea Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:37:14 -0600 Subject: [PATCH 008/104] wip(desktop): progress --- packages/desktop/src/app.tsx | 11 +- .../src/components/dialog-select-file.tsx | 11 +- .../desktop/src/components/prompt-input.tsx | 78 +++-- packages/desktop/src/components/terminal.tsx | 2 +- packages/desktop/src/context/command.tsx | 2 +- packages/desktop/src/context/layout.tsx | 92 ++++- packages/desktop/src/context/prompt.tsx | 100 ++++++ packages/desktop/src/context/session.tsx | 321 ------------------ packages/desktop/src/context/terminal.tsx | 106 ++++++ packages/desktop/src/pages/session.tsx | 184 ++++++---- 10 files changed, 483 insertions(+), 424 deletions(-) create mode 100644 packages/desktop/src/context/prompt.tsx delete mode 100644 packages/desktop/src/context/session.tsx create mode 100644 packages/desktop/src/context/terminal.tsx diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index 6414d0d49..2530f92dd 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { SessionProvider } from "@/context/session" +import { TerminalProvider } from "@/context/terminal" +import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -53,9 +54,11 @@ export function App() { path="/session/:id?" component={(p) => ( - - - + + + + + )} /> diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..b719e15d2 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } dialog.clear() }} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 6ab280fa6..a498593bd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -4,9 +4,10 @@ import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -67,12 +68,26 @@ export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() let editorRef!: HTMLDivElement + // Session-derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + const [store, setStore] = createStore<{ popover: "file" | "slash" | null historyIndex: number @@ -111,9 +126,9 @@ export const PromptInput: Component = (props) => { const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) @@ -149,9 +164,9 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -211,7 +226,7 @@ export const PromptInput: Component = (props) => { if (!cmd) return // Since slash commands only trigger from start, just clear the input editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -243,7 +258,7 @@ export const PromptInput: Component = (props) => { createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -255,7 +270,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part) => { + currentParts.forEach((part: ContentPart) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -333,7 +348,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -341,8 +356,8 @@ export const PromptInput: Component = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -403,7 +418,7 @@ export const PromptInput: Component = (props) => { const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { @@ -430,7 +445,7 @@ export const PromptInput: Component = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -481,7 +496,7 @@ export const PromptInput: Component = (props) => { const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) + const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 const isStart = cursorPos === 0 const isEnd = cursorPos === textLength @@ -511,7 +526,7 @@ export const PromptInput: Component = (props) => { if (event.key === "Escape") { if (store.popover) { setStore("popover", null) - } else if (session.working()) { + } else if (working()) { abort() } } @@ -519,18 +534,18 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") + const currentPrompt = prompt.current() + const text = currentPrompt.map((part: ContentPart) => part.content).join("") if (text.trim().length === 0) { - if (session.working()) abort() + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -539,7 +554,9 @@ export const PromptInput: Component = (props) => { if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") + const attachments = currentPrompt.filter( + (part: ContentPart) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) @@ -563,10 +580,9 @@ export const PromptInput: Component = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) sdk.client.session.prompt({ sessionID: existing.id, @@ -671,7 +687,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -703,7 +719,7 @@ export const PromptInput: Component = (props) => { inactive={!session.prompt.dirty() && !session.working()} value={ - +
Stop ESC @@ -720,8 +736,8 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx index b17a98270..26b03f980 100644 --- a/packages/desktop/src/context/command.tsx +++ b/packages/desktop/src/context/command.tsx @@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) { search={{ placeholder: "Search commands", autofocus: true }} emptyMessage="No commands found" items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} - key={(x) => x.id} + key={(x) => x?.id} groupBy={(x) => x.category ?? ""} onSelect={(option) => { if (option) { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..c3b3bbace --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,100 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export type ContentPart = TextPart | FileAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index e3cac4842..48e01239c 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -27,22 +27,91 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { useCommand } from "@/context/command" +import { useParams } from "@solidjs/router" +import { pipe, sumBy } from "remeda" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() + const prompt = usePrompt() const dialog = useDialog() const command = useCommand() + const params = useParams() + + // Session-specific derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + const lastUserMessage = createMemo(() => userMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + return userMessages()?.find((m) => m.id === messageStore.messageId) + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -50,10 +119,15 @@ export default function Page() { }) let inputRef!: HTMLDivElement + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) + }) + createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) @@ -99,7 +173,7 @@ export default function Page() { description: "Create a new terminal tab", category: "Terminal", keybind: "ctrl+shift+`", - onSelect: () => session.terminal.new(), + onSelect: () => terminal.new(), }, ]) @@ -166,11 +240,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -188,11 +262,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -210,8 +284,8 @@ export default function Page() { 1 && ( - session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + terminal.close(props.terminal.id)} /> ) } > @@ -326,7 +400,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return (
@@ -339,7 +413,7 @@ export default function Page() { > - +
@@ -349,15 +423,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - -
{session.usage.context() ?? 0}%
+ +
{context() ?? 0}%
- +
- - + +
Review
- +
- {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0}
- - - {(tab) => ( - - )} + + + {(tab) => }
@@ -415,27 +487,23 @@ export default function Page() { }} > - +
1 - ? "pr-6 pl-18" - : "px-6"), + (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"), }} />
@@ -476,7 +544,7 @@ export default function Page() {
- +
{ layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> @@ -506,7 +574,7 @@ export default function Page() {
- +
- + {(tab) => { const [file] = createResource( () => tab, @@ -579,7 +647,7 @@ export default function Page() {
- +
{ @@ -639,25 +707,21 @@ export default function Page() { > - + - t.id)}> - {(terminal) => } + t.id)}> + {(pty) => }
- +
- - {(terminal) => ( - - session.terminal.clone(terminal.id)} - /> + + {(pty) => ( + + terminal.clone(pty.id)} /> )} @@ -665,9 +729,9 @@ export default function Page() { {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - + {(t) => (
{t().title} From 3a14ca044ca521d1ab24c04da8e9e3bab9e2de58 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:43:01 -0600 Subject: [PATCH 009/104] wip(desktop): progress --- packages/desktop/src/context/local.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 56154c5ba..6ec9778cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() From acd91bddf7e83591d60f6801b7d34685a7f3f256 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:58:01 -0600 Subject: [PATCH 010/104] wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 5 +++++ packages/ui/src/components/dialog.tsx | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 48e01239c..caea614c9 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -185,6 +185,11 @@ export default function Page() { return } + // Don't interfere with dialogs + if (dialog.stack.length > 0) { + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 47d6af42e..40a6ac83d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) { ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} + onOpenAutoFocus={(e) => { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} >
From ece3bfd93d88584dbab4e680a1cc56efc5113b9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:06:14 -0600 Subject: [PATCH 011/104] wip(desktop): progress --- packages/ui/src/components/message-part.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9d4214bae..b66ef1d27 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -107,15 +107,19 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; width: 100%; [data-slot="message-part-title-area"] { + flex-grow: 1; display: flex; align-items: center; gap: 8px; + min-width: 0; } [data-slot="message-part-title"] { + flex-shrink: 0; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; @@ -128,14 +132,22 @@ [data-slot="message-part-path"] { display: flex; + flex-grow: 1; + min-width: 0; } [data-slot="message-part-directory"] { color: var(--text-weak); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; } [data-slot="message-part-filename"] { color: var(--text-strong); + flex-shrink: 0; } [data-slot="message-part-actions"] { From d81d63045ac619bc9201df4ae2e003a2d08596d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:18:39 -0600 Subject: [PATCH 012/104] wip(desktop): session turn state consolidation --- packages/ui/src/components/session-turn.tsx | 626 ++++++++++---------- 1 file changed, 306 insertions(+), 320 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..e6654f480 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,6 +40,9 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + + if (!message()) return null + const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -49,379 +52,362 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [state, setState] = createStore({ + + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message()!.id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") + resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + lastStatusChange: Date.now(), + statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return + setStore("scrollY", scrollRef.scrollTop) + if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function handleInteraction() { if (working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return + setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setState("autoScrolling", false) + setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setState("userScrolled", false) + setStore("userScrolled", false) } }) createResizeObserver( - () => state.stickyTitleRef, + () => store.stickyTitleRef, ({ height }) => { - const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => state.stickyTriggerRef, + () => store.stickyTriggerRef, ({ height }) => { - const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) + createEffect(() => { + lastPart() + scrollToBottom() + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - store.lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + setStore("lastStatusChange", Date.now()) + if (store.statusTimeout) { + clearTimeout(store.statusTimeout) + setStore("statusTimeout", undefined) + } + } else { + if (store.statusTimeout) clearTimeout(store.statusTimeout) + setStore( + "statusTimeout", + setTimeout(() => { + setStore("status", rawStatus()) + setStore("lastStatusChange", Date.now()) + setStore("statusTimeout", undefined) + }, 2500 - timeSinceLastChange) as unknown as number, + ) + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + return ( -
+
- - {(message) => { - const assistantMessages = createMemo(() => { - return messages()?.filter( - (m) => m.role === "assistant" && m.parentID == message().id, - ) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message().id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo( - () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, - ) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( - (m) => m.role === "assistant", - ) - resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - createEffect(() => { - lastPart() - scrollToBottom() - }) - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - }) - - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) - - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) - - return ( -
- {/* Title (sticky) */} -
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + p?.id !== last()?.id)} /> + + + + - · - {store.duration} - - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - - -
-
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- ) - }} - + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
{props.children}
From 40572eeba4b94d411934c7ff95ce51ba88c9562f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:19:20 -0600 Subject: [PATCH 013/104] wip(desktop): progress --- packages/ui/src/components/list.css | 1 + packages/ui/src/components/list.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index cd9e73d1d..368065e53 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -112,6 +112,7 @@ padding: 4px 10px; align-items: center; color: var(--text-strong); + scroll-margin-top: 28px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec6e159d..0ed745f32 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -79,7 +79,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + element?.scrollIntoView({ block: "center", behavior: "smooth" }) }) const handleSelect = (item: T | undefined, index: number) => { From 82c4755fb01f3ae821569af80d1b8670fe69fe56 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:26:32 -0600 Subject: [PATCH 014/104] wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 24 +++++++++++---------- packages/ui/src/components/session-turn.tsx | 3 --- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index caea614c9..66a2fcec7 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -500,17 +500,19 @@ export default function Page() { onMessageSelect={setActiveMessage} wide={wide()} /> - 1 ? "pr-6 pl-18" : "px-6"), - }} - /> + + 1 ? "pr-6 pl-18" : "px-6"), + }} + /> +
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e6654f480..281b2a03b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,9 +40,6 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) - - if (!message()) return null - const status = createMemo( () => data.store.session_status[props.sessionID] ?? { From 88c06751487291416eb473831cda70455f67412a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:41:37 -0600 Subject: [PATCH 015/104] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 21 +++---------------- packages/desktop/src/pages/layout.tsx | 3 +++ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index a498593bd..fd5574990 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -93,13 +93,11 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number - slashFilter: string }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - slashFilter: "", }) const MAX_HISTORY = 100 @@ -237,25 +235,12 @@ export const PromptInput: Component = (props) => { onInput: slashOnInput, onKeyDown: slashOnKeyDown, } = useFilteredList({ - items: () => { - const filter = store.slashFilter.toLowerCase() - return slashCommands().filter( - (cmd) => - cmd.trigger.toLowerCase().includes(filter) || - cmd.title.toLowerCase().includes(filter) || - cmd.description?.toLowerCase().includes(filter) || - false, - ) - }, + items: slashCommands, key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) - // Update slash filter when store changes - createEffect(() => { - slashOnInput(store.slashFilter) - }) - createEffect( on( () => prompt.current(), @@ -337,7 +322,7 @@ export const PromptInput: Component = (props) => { onInput(atMatch[1]) setStore("popover", "file") } else if (slashMatch) { - setStore("slashFilter", slashMatch[1]) + slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { setStore("popover", null) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 965ade9f8..08d24dc6f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -299,6 +299,9 @@ export default function Layout(props: ParentProps) { if (match.found) draft.session.splice(match.index, 1) }), ) + if (session.id === params.id) { + navigate(`/${params.dir}/session`) + } } return (
Date: Mon, 15 Dec 2025 05:42:25 -0600 Subject: [PATCH 016/104] Revert "wip(desktop): session turn state consolidation" This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1. --- packages/ui/src/components/session-turn.tsx | 623 ++++++++++---------- 1 file changed, 320 insertions(+), 303 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 281b2a03b..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -49,362 +49,379 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - - const assistantMessages = createMemo(() => { - return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message()!.id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") - resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - const [store, setStore] = createStore({ + const [state, setState] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - lastStatusChange: Date.now(), - statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setStore("scrollY", scrollRef.scrollTop) - if (store.autoScrolling) return + setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return - setStore("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setStore("autoScrolling", false) + setState("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setStore("userScrolled", false) + setState("userScrolled", false) } }) createResizeObserver( - () => store.stickyTitleRef, + () => state.stickyTitleRef, ({ height }) => { - const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => store.stickyTriggerRef, + () => state.stickyTriggerRef, ({ height }) => { - const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) }, ) - createEffect(() => { - lastPart() - scrollToBottom() - }) - - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) - - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - store.lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - setStore("lastStatusChange", Date.now()) - if (store.statusTimeout) { - clearTimeout(store.statusTimeout) - setStore("statusTimeout", undefined) - } - } else { - if (store.statusTimeout) clearTimeout(store.statusTimeout) - setStore( - "statusTimeout", - setTimeout(() => { - setStore("status", rawStatus()) - setStore("lastStatusChange", Date.now()) - setStore("statusTimeout", undefined) - }, 2500 - timeSinceLastChange) as unknown as number, - ) - } - }) - - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !store.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) - return ( -
+
-
- {/* Title (sticky) */} -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message()!.summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( + + {(message) => { + const assistantMessages = createMemo(() => { + return messages()?.filter( + (m) => m.role === "assistant" && m.parentID == message().id, + ) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message().id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo( + () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, + ) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( + (m) => m.role === "assistant", + ) + resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + createEffect(() => { + lastPart() + scrollToBottom() + }) + + const [store, setStore] = createStore({ + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + lastStatusChange = Date.now() + if (statusTimeout) { + clearTimeout(statusTimeout) + statusTimeout = undefined + } + } else { + if (statusTimeout) clearTimeout(statusTimeout) + statusTimeout = setTimeout(() => { + setStore("status", rawStatus()) + lastStatusChange = Date.now() + statusTimeout = undefined + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !state.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + + return ( +
+ {/* Title (sticky) */} +
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> +
+
+ + + + + +

{message().summary?.title}

+
+
+
+
+
+ {/* User Message */} +
+ +
+ {/* Trigger (sticky) */} +
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> +
- - {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} + · + {store.duration} + + +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )} + +
+ + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} +
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - - -
+ ) + }} +
{props.children}
From c36f3b9dbe5547576545a77679b8898c205a0c30 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:50:10 -0600 Subject: [PATCH 017/104] wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 4 +++- packages/ui/src/assets/audio/nope-01.aac | Bin 0 -> 6316 bytes packages/ui/src/assets/audio/nope-02.aac | Bin 0 -> 7431 bytes packages/ui/src/assets/audio/nope-03.aac | Bin 0 -> 6688 bytes packages/ui/src/assets/audio/nope-04.aac | Bin 0 -> 5573 bytes packages/ui/src/assets/audio/nope-05.aac | Bin 0 -> 6316 bytes 6 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/audio/nope-01.aac create mode 100644 packages/ui/src/assets/audio/nope-02.aac create mode 100644 packages/ui/src/assets/audio/nope-03.aac create mode 100644 packages/ui/src/assets/audio/nope-04.aac create mode 100644 packages/ui/src/assets/audio/nope-05.aac diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 744e4fdf3..c18b12796 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,6 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/error-3.aac" type NotificationBase = { directory?: string @@ -29,6 +30,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi name: "Notification", init: () => { const idlePlayer = makeAudioPlayer(idleSound) + const errorPlayer = makeAudioPlayer(errorSound) const globalSDK = useGlobalSDK() const [store, setStore] = makePersisted( @@ -65,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi break } case "session.error": { + errorPlayer.play() const session = event.properties.sessionID ?? "global" - // errorPlayer.play() setStore("list", store.list.length, { ...base, type: "error", diff --git a/packages/ui/src/assets/audio/nope-01.aac b/packages/ui/src/assets/audio/nope-01.aac new file mode 100644 index 0000000000000000000000000000000000000000..9fb614d0808026ac186a8641d4dd9e5f1bff6073 GIT binary patch literal 6316 zcmezWF`z*&$wg7ol|dwc(e3|7uBcG}3xU`di$u0i8|?WH404M{GKxOO8rc5>!7ldg zXARBY{;EFTwaD&X`>gX<_ig|3|Mhyo@4vpjan)J9Qrjy;dG9R$whu|w`$RnCZ~IvyVad{5=$|%~S{&DioX1(OmE( z&*+b!0{ed@4@af`_pUkd+kPDrp1XRJwaJR5p8T#i_51roch9Z(w>{*}>wxY4HkZ>$+XfZySuX4eJQDnf>0;{4C3~2+HZGbp zH+jY1q>ELNye1LWt7j`{_4w$t=+FD@r16?dW73zTcL8_ZIu_mR+F;X= zm19N|vVx<}l*ul+H7R&wq_627r5SB8Gqq(V-0;$_u3}y8bGcxW^Pv!dP?1Xp_d0`S za?4H~=pBE#R|pSISQuD-oq{mc0s&!euIO3o0zHKAD5B0=!Z zW>Gh-pjWA8F0XxT!WsM}EJYXTiHhkT_@{Sb&#IRzBi^RXQDnTS@=A`&(CmuWjSUz3 znG2&Ne@amo)PJ^EXT4O_w;#pFka)0TCHlZ=4|$sqe_dLIF8P; zF-!@|+oznL*>`28yYuw#54cz?&#mEKyJn4x5isz-aA{721ilKedXSpDSi#Onk~g(k z?mI&Tdl!o&bMy5FC+|M_l<@4#nahk{w@%}!zQg!HPD3DtFaNH<(h&n5fYPU4lZ&F@ zXblRhHxYE~i$m0}LAQXbP*AI7G|Uk}4QJ7=)iRpQ;em|G7)|D=;Xx1HXfmfqD5Kah zn#@sxgD#xWWKNf0Mz&-$nIne=eb}SPoIc@W>;BG&7WMqnfhrG0a)TvgT2mj?oIJEdxb60h_PIIc4F$Ru{Oy!C^_|Hy5AVejnBya!#MV?Tk literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-02.aac b/packages/ui/src/assets/audio/nope-02.aac new file mode 100644 index 0000000000000000000000000000000000000000..75603cc16f8596b1a787d9b58f6bb9d843f21dc4 GIT binary patch literal 7431 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 If54sp19W#k{r~^~ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-03.aac b/packages/ui/src/assets/audio/nope-03.aac new file mode 100644 index 0000000000000000000000000000000000000000..1fe459a16e8de42721ba6813a0699fb019b6a741 GIT binary patch literal 6688 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 P!~Zw<%k2Ld{N{fE4(1i- literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-04.aac b/packages/ui/src/assets/audio/nope-04.aac new file mode 100644 index 0000000000000000000000000000000000000000..b731a2a0790b6a90dffd9520d2d75510077b4ed2 GIT binary patch literal 5573 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 literal 0 HcmV?d00001 From c0d009d5f33c368f61ebe9a87460b1fbf5801d33 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:54:44 -0600 Subject: [PATCH 018/104] wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index c18b12796..705551944 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,7 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" -import errorSound from "@opencode-ai/ui/audio/error-3.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" type NotificationBase = { directory?: string diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 08d24dc6f..df162c187 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project } from "@opencode-ai/sdk/v2/client" @@ -287,6 +288,11 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) const notifications = createMemo(() => notification.session.unseen(session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy", + ) async function archive(session: Session) { await globalSDK.client.session.update({ directory: session.directory, @@ -319,6 +325,9 @@ export default function Layout(props: ParentProps) {
+ + +
From 315836c0b7f7f852c99d8c9945fa15e63a3ea2bc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:06:05 -0600 Subject: [PATCH 019/104] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 6 +- packages/desktop/src/pages/session.tsx | 64 ++++++++----------- 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index fd5574990..4dd8d878c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -591,8 +591,8 @@ export const PromptInput: Component = (props) => { {/* Popover for file mentions and slash commands */}
@@ -602,7 +602,7 @@ export const PromptInput: Component = (props) => { {(i) => (
From df2ebfac7d3dca6c2262258e6ee85a3c22cc53c3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:52:54 -0600 Subject: [PATCH 023/104] wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 +++++ packages/desktop/src/pages/layout.tsx | 6 ++ packages/desktop/src/pages/session.tsx | 69 ++++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 20 +++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index af71c6a00..604f7c5d1 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + steps: { + expanded: false, + }, sessionTabs: {} as Record, }), { @@ -161,6 +164,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + steps: { + expanded: createMemo(() => store.steps?.expanded ?? false), + toggle() { + setStore("steps", "expanded", (x) => !x) + }, + expand() { + setStore("steps", "expanded", true) + }, + collapse() { + setStore("steps", "expanded", false) + }, + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index bb2302503..53078e01b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -156,6 +156,12 @@ export default function Layout(props: ParentProps) { }, ] : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, { id: "session.previous", title: "Previous session", diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 5c74f2d2e..d0c3bf7de 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" @@ -70,6 +71,25 @@ export default function Page() { setMessageStore("messageId", message?.id) } + function navigateMessageByOffset(offset: number) { + const messages = userMessages() + if (messages.length === 0) return + + const current = activeMessage() + const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : messages.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= messages.length) return + + setActiveMessage(messages[targetIndex]) + } + const last = createMemo( () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, ) @@ -118,7 +138,7 @@ export default function Page() { title: "New session", description: "Create a new session", category: "Session", - keybind: "mod+n", + keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, @@ -163,6 +183,49 @@ export default function Page() { keybind: "ctrl+shift+`", onSelect: () => terminal.new(), }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + onSelect: () => layout.steps.toggle(), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + slash: "model", + onSelect: () => dialog.replace(() => ), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + slash: "agent", + onSelect: () => local.agent.move(1), + }, ]) // Handle keyboard events that aren't commands @@ -492,6 +555,10 @@ export default function Page() { + expanded ? layout.steps.expand() : layout.steps.collapse() + } classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 54dd01091..807092d03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -24,6 +24,8 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + stepsExpanded?: boolean + onStepsExpandedChange?: (expanded: boolean) => void classes?: { root?: string content?: string @@ -222,10 +224,17 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: working(), + stepsExpanded: props.stepsExpanded ?? working(), duration: duration(), }) + // Sync with controlled prop + createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) + createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) @@ -262,6 +271,7 @@ export function SessionTurn( const isWorking = working() if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) } return isWorking }, working()) @@ -278,7 +288,7 @@ export function SessionTurn(
- + @@ -298,7 +308,11 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} + onClick={() => { + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} > From 5e37a902ce0ad209cedb0a85e997f1964064424a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:59:01 -0600 Subject: [PATCH 024/104] wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 --------------- packages/desktop/src/pages/session.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 604f7c5d1..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,9 +46,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, - steps: { - expanded: false, - }, sessionTabs: {} as Record, }), { @@ -164,18 +161,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - steps: { - expanded: createMemo(() => store.steps?.expanded ?? false), - toggle() { - setStore("steps", "expanded", (x) => !x) - }, - expand() { - setStore("steps", "expanded", true) - }, - collapse() { - setStore("steps", "expanded", false) - }, - }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d0c3bf7de..d49779587 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -115,6 +115,7 @@ export default function Page() { clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, + stepsExpanded: false, }) let inputRef!: HTMLDivElement @@ -190,7 +191,8 @@ export default function Page() { category: "View", keybind: "mod+e", slash: "steps", - onSelect: () => layout.steps.toggle(), + disabled: !params.id, + onSelect: () => setStore("stepsExpanded", (x) => !x), }, { id: "message.previous", @@ -555,10 +557,8 @@ export default function Page() { - expanded ? layout.steps.expand() : layout.steps.collapse() - } + stepsExpanded={store.stepsExpanded} + onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", From ff6864a7ca3772e6f2702d585c6bb64a40bd6cce Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:05:50 -0600 Subject: [PATCH 025/104] feat(desktop): custom commands --- .../desktop/src/components/prompt-input.tsx | 74 ++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 4 + 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 51c4e24d2..87f91104c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -61,6 +61,7 @@ interface SlashCommand { title: string description?: string keybind?: string + type: "builtin" | "custom" } export const PromptInput: Component = (props) => { @@ -208,8 +209,8 @@ export const PromptInput: Component = (props) => { }) // Get slash commands from registered commands (only those with explicit slash trigger) - const slashCommands = createMemo(() => - command.options + const slashCommands = createMemo(() => { + const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) .map((opt) => ({ id: opt.id, @@ -217,15 +218,46 @@ export const PromptInput: Component = (props) => { title: opt.title, description: opt.description, keybind: opt.keybind, - })), - ) + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + })) + + return [...custom, ...builtin] + }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - // Since slash commands only trigger from start, just clear the input + setStore("popover", null) + + if (cmd.type === "custom") { + // For custom commands, insert the command text so user can add arguments + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + // Set cursor at end + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + + // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -571,6 +603,23 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + // Check if this is a custom command + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) // Remove leading "/" + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + sdk.client.session.command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent: local.agent.current()!.name, + model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`, + }) + return + } + } + sdk.client.session.prompt({ sessionID: existing.id, agent: local.agent.current()!.name, @@ -641,9 +690,16 @@ export const PromptInput: Component = (props) => { {cmd.description}
- - {formatKeybind(cmd.keybind!)} - +
+ + + custom + + + + {formatKeybind(cmd.keybind!)} + +
)} diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..b90dde34f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -118,6 +121,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:14:03 -0600 Subject: [PATCH 026/104] chore: cleanup --- .../desktop/src/components/prompt-input.tsx | 21 ++++++------------- packages/desktop/src/context/command.tsx | 17 --------------- packages/desktop/src/pages/session.tsx | 19 +++-------------- packages/ui/src/components/session-turn.tsx | 1 - 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 87f91104c..9be09507a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -77,7 +77,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement - // Session-derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) @@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef?.addEventListener("paste", handlePaste) + editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef?.removeEventListener("paste", handlePaste) + editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { @@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) - // Get slash commands from registered commands (only those with explicit slash trigger) const slashCommands = createMemo(() => { const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) @@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - // For custom commands, insert the command text so user can add arguments const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - // Set cursor at end requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => { return } - // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part: ContentPart) => { + currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") + const rawText = currentPrompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - // Handle popover navigation if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) @@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => { } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - // Skip history navigation when modifier keys are pressed (used for other commands) if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return @@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part: ContentPart) => part.content).join("") + const text = currentPrompt.map((part) => part.content).join("") if (text.trim().length === 0) { if (working()) abort() return @@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => { const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( - (part: ContentPart) => part.type === "file", + (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { @@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - // Check if this is a custom command if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) // Remove leading "/" @@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => { return (
- {/* Popover for file mentions and slash commands */}
void } @@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return - // Check for command palette keybind (mod+shift+p) const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() @@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return } - // Check registered command keybinds for (const option of options()) { if (option.disabled) continue if (!option.keybind) continue diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d49779587..9e743e48f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -49,7 +49,6 @@ export default function Page() { const params = useParams() const navigate = useNavigate() - // Session-specific derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -132,7 +131,6 @@ export default function Page() { } }) - // Register commands for this page command.register(() => [ { id: "session.new", @@ -230,28 +228,17 @@ export default function Page() { }, ]) - // Handle keyboard events that aren't commands const handleKeyDown = (event: KeyboardEvent) => { - // Don't interfere with terminal // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } - - // Don't interfere with dialogs - if (dialog.stack.length > 0) { - return - } + if (document.activeElement?.dataset?.component === "terminal") return + if (dialog.stack.length > 0) return const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 807092d03..f905abbd1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,7 +228,6 @@ export function SessionTurn( duration: duration(), }) - // Sync with controlled prop createEffect(() => { if (props.stepsExpanded !== undefined) { setStore("stepsExpanded", props.stepsExpanded) From 5eaa8e1bf4862bfc64f114f7e9b31fc22e79be44 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:18:15 -0600 Subject: [PATCH 027/104] chore: cleanup --- packages/desktop/src/pages/session.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 9e743e48f..05a9e8a1d 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -233,6 +233,19 @@ export default function Page() { if (document.activeElement?.dataset?.component === "terminal") return if (dialog.stack.length > 0) return + if (event.key === "PageUp" || event.key === "PageDown") { + const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement + if (scrollContainer) { + event.preventDefault() + const scrollAmount = scrollContainer.clientHeight * 0.8 + scrollContainer.scrollBy({ + top: event.key === "PageUp" ? -scrollAmount : scrollAmount, + behavior: "instant", + }) + } + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") inputRef?.blur() From 44d6c5780d41616bf29a749020c9d7f98895407f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:25:24 -0600 Subject: [PATCH 028/104] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 9be09507a..37d05c311 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -133,31 +133,20 @@ export const PromptInput: Component = (props) => { }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } @@ -505,17 +494,13 @@ export const PromptInput: Component = (props) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:34:00 -0600 Subject: [PATCH 029/104] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 191 +++++++++++++--- packages/desktop/src/context/global-sync.tsx | 40 +++- packages/desktop/src/context/local.tsx | 2 +- packages/desktop/src/context/prompt.tsx | 14 +- packages/desktop/src/pages/layout.tsx | 211 ++++++++++++------ packages/desktop/src/pages/session.tsx | 148 ++++++++---- packages/desktop/src/utils/prompt.ts | 47 ++++ packages/ui/src/components/message-part.css | 76 ++++++- packages/ui/src/components/message-part.tsx | 96 +++++++- packages/ui/src/components/session-turn.tsx | 10 +- 10 files changed, 676 insertions(+), 159 deletions(-) create mode 100644 packages/desktop/src/utils/prompt.ts diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => { + +
+
+ + Drop images or PDFs here +
+
+
+ 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} + + +
+ {attachment.filename} +
+
+ )} + +
+
{ @@ -706,7 +845,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => { > { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {