mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-12 11:54:29 +00:00
Compare commits
1 Commits
brendan/de
...
sqlite2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64bd21c66d |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -8,7 +8,6 @@
|
||||
# - Denounce with minus prefix: -username or -platform:username.
|
||||
# - Optional details after a space following the handle.
|
||||
adamdotdevin
|
||||
-florianleibert
|
||||
fwang
|
||||
iamdavidhill
|
||||
jayair
|
||||
|
||||
11
.github/workflows/docs-locale-sync.yml
vendored
11
.github/workflows/docs-locale-sync.yml
vendored
@@ -64,13 +64,10 @@ jobs:
|
||||
|
||||
Requirements:
|
||||
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
|
||||
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
|
||||
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
|
||||
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
|
||||
5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||
6. Keep locale docs structure aligned with their corresponding English pages.
|
||||
7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||
8. If no locale updates are needed, make no changes.
|
||||
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||
3. Keep locale docs structure aligned with their corresponding English pages.
|
||||
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||
5. If no locale updates are needed, make no changes.
|
||||
|
||||
- name: Commit and push locale docs updates
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
|
||||
@@ -32,9 +32,6 @@ description: Use this when you are working on file operations like reading, writ
|
||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||
|
||||
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
||||
Use Filesystem.exists(...) instead if path can be file or directory
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- Use Bun APIs first.
|
||||
|
||||
26
bun.lock
26
bun.lock
@@ -301,8 +301,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.79",
|
||||
"@opentui/solid": "0.1.79",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -525,7 +525,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -1298,21 +1298,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1872,7 +1872,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -2206,7 +2206,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
|
||||
"aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
|
||||
"aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
|
||||
"x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
|
||||
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
|
||||
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
|
||||
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
|
||||
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.9",
|
||||
"packageManager": "bun@1.3.8",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -23,7 +23,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.8",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 697 B |
@@ -1,18 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86283)">
|
||||
<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86283)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86283">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 631 B |
Binary file not shown.
|
Before Width: | Height: | Size: 697 B |
@@ -1,18 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86274)">
|
||||
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86274)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86274">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 631 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -7,24 +7,18 @@ import { useI18n } from "~/context/i18n"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
|
||||
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
|
||||
import previewLogoLightSquare from "../../asset/brand/preview-opencode-logo-light-square.png"
|
||||
import previewLogoDarkSquare from "../../asset/brand/preview-opencode-logo-dark-square.png"
|
||||
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
|
||||
import previewWordmarkDark from "../../asset/brand/preview-opencode-wordmark-dark.png"
|
||||
import previewWordmarkSimpleLight from "../../asset/brand/preview-opencode-wordmark-simple-light.png"
|
||||
import previewWordmarkSimpleDark from "../../asset/brand/preview-opencode-wordmark-simple-dark.png"
|
||||
import logoLightPng from "../../asset/brand/opencode-logo-light.png"
|
||||
import logoDarkPng from "../../asset/brand/opencode-logo-dark.png"
|
||||
import logoLightSquarePng from "../../asset/brand/opencode-logo-light-square.png"
|
||||
import logoDarkSquarePng from "../../asset/brand/opencode-logo-dark-square.png"
|
||||
import wordmarkLightPng from "../../asset/brand/opencode-wordmark-light.png"
|
||||
import wordmarkDarkPng from "../../asset/brand/opencode-wordmark-dark.png"
|
||||
import wordmarkSimpleLightPng from "../../asset/brand/opencode-wordmark-simple-light.png"
|
||||
import wordmarkSimpleDarkPng from "../../asset/brand/opencode-wordmark-simple-dark.png"
|
||||
import logoLightSvg from "../../asset/brand/opencode-logo-light.svg"
|
||||
import logoDarkSvg from "../../asset/brand/opencode-logo-dark.svg"
|
||||
import logoLightSquareSvg from "../../asset/brand/opencode-logo-light-square.svg"
|
||||
import logoDarkSquareSvg from "../../asset/brand/opencode-logo-dark-square.svg"
|
||||
import wordmarkLightSvg from "../../asset/brand/opencode-wordmark-light.svg"
|
||||
import wordmarkDarkSvg from "../../asset/brand/opencode-wordmark-dark.svg"
|
||||
import wordmarkSimpleLightSvg from "../../asset/brand/opencode-wordmark-simple-light.svg"
|
||||
@@ -141,60 +135,6 @@ export default function Brand() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
|
||||
PNG
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => downloadFile(logoLightSquareSvg, "opencode-logo-light-square.svg")}>
|
||||
SVG
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
|
||||
PNG
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => downloadFile(logoDarkSquareSvg, "opencode-logo-dark-square.svg")}>
|
||||
SVG
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
|
||||
13
packages/desktop/src-tauri/Cargo.lock
generated
13
packages/desktop/src-tauri/Cargo.lock
generated
@@ -3117,7 +3117,6 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -5632,18 +5631,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
|
||||
@@ -51,7 +51,6 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
chrono = "0.4"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -1,55 +1,35 @@
|
||||
use futures::{FutureExt, Stream, StreamExt, future};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
process::{CommandChild, CommandEvent, TerminatedPayload},
|
||||
process::{Command, CommandChild, CommandEvent, TerminatedPayload},
|
||||
};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::Event;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub hostname: Option<String>,
|
||||
pub port: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
|
||||
)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum SqliteMigrationProgress {
|
||||
InProgress(u8),
|
||||
Done,
|
||||
}
|
||||
|
||||
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
|
||||
|
||||
events
|
||||
.fold(String::new(), async |mut config_str, event| {
|
||||
if let CommandEvent::Stdout(stdout) = event
|
||||
&& let Ok(s) = str::from_utf8(&stdout)
|
||||
{
|
||||
config_str += s
|
||||
}
|
||||
|
||||
config_str
|
||||
})
|
||||
.map(|v| serde_json::from_str::<Config>(&v))
|
||||
create_command(app, "debug config", &[])
|
||||
.output()
|
||||
.await
|
||||
.inspect_err(|e| tracing::warn!("Failed to read OC config: {e}"))
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
|
||||
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
|
||||
}
|
||||
|
||||
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||
@@ -195,11 +175,7 @@ fn shell_escape(input: &str) -> String {
|
||||
escaped
|
||||
}
|
||||
|
||||
pub fn spawn_command(
|
||||
app: &tauri::AppHandle,
|
||||
args: &str,
|
||||
extra_env: &[(&str, String)],
|
||||
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), tauri_plugin_shell::Error> {
|
||||
pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command {
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
@@ -226,7 +202,7 @@ pub fn spawn_command(
|
||||
.map(|(key, value)| (key.to_string(), value.clone())),
|
||||
);
|
||||
|
||||
let cmd = if cfg!(windows) {
|
||||
if cfg!(windows) {
|
||||
if is_wsl_enabled(app) {
|
||||
tracing::info!("WSL is enabled, spawning CLI server in WSL");
|
||||
let version = app.package_info().version.to_string();
|
||||
@@ -258,9 +234,10 @@ pub fn spawn_command(
|
||||
|
||||
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
|
||||
|
||||
app.shell()
|
||||
return app
|
||||
.shell()
|
||||
.command("wsl")
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")])
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")]);
|
||||
} else {
|
||||
let mut cmd = app
|
||||
.shell()
|
||||
@@ -272,7 +249,7 @@ pub fn spawn_command(
|
||||
cmd = cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
return cmd;
|
||||
}
|
||||
} else {
|
||||
let sidecar = get_sidecar_path(app);
|
||||
@@ -291,49 +268,7 @@ pub fn spawn_command(
|
||||
}
|
||||
|
||||
cmd
|
||||
};
|
||||
|
||||
let (rx, child) = cmd.spawn()?;
|
||||
let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
let event_stream = handle_sqlite_migration_logs(app.clone(), event_stream);
|
||||
|
||||
Ok((event_stream, child))
|
||||
}
|
||||
|
||||
fn handle_sqlite_migration_logs(
|
||||
app: AppHandle,
|
||||
stream: impl Stream<Item = CommandEvent>,
|
||||
) -> impl Stream<Item = CommandEvent> {
|
||||
let app = app.clone();
|
||||
let mut done = false;
|
||||
|
||||
stream.filter_map(move |event| {
|
||||
if done {
|
||||
return future::ready(Some(event));
|
||||
}
|
||||
|
||||
future::ready(match &event {
|
||||
CommandEvent::Stdout(stdout) => {
|
||||
let Ok(s) = str::from_utf8(stdout) else {
|
||||
return future::ready(None);
|
||||
};
|
||||
|
||||
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
||||
if let Ok(progress) = s.parse::<u8>() {
|
||||
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
||||
} else if s == "done" {
|
||||
done = true;
|
||||
let _ = SqliteMigrationProgress::Done.emit(&app);
|
||||
}
|
||||
|
||||
None
|
||||
} else {
|
||||
Some(event)
|
||||
}
|
||||
}
|
||||
_ => Some(event),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
@@ -351,47 +286,45 @@ pub fn serve(
|
||||
("OPENCODE_SERVER_PASSWORD", password.to_string()),
|
||||
];
|
||||
|
||||
let (events, child) = spawn_command(
|
||||
let (mut rx, child) = create_command(
|
||||
app,
|
||||
format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
|
||||
&envs,
|
||||
)
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
let mut exit_tx = Some(exit_tx);
|
||||
tokio::spawn(
|
||||
events
|
||||
.for_each(move |event| {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
tracing::error!("{err}");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
tracing::info!(
|
||||
code = ?payload.code,
|
||||
signal = ?payload.signal,
|
||||
"Sidecar terminated"
|
||||
);
|
||||
|
||||
if let Some(tx) = exit_tx.take() {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
tokio::spawn(async move {
|
||||
let mut exit_tx = Some(exit_tx);
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!(target: "sidecar", "{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
tracing::info!(target: "sidecar", "{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
tracing::error!(target: "sidecar", "{err}");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
tracing::info!(
|
||||
target: "sidecar",
|
||||
code = ?payload.code,
|
||||
signal = ?payload.signal,
|
||||
"Sidecar terminated"
|
||||
);
|
||||
|
||||
future::ready(())
|
||||
})
|
||||
.instrument(tracing::info_span!("sidecar")),
|
||||
);
|
||||
if let Some(tx) = exit_tx.take() {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(child, exit_rx)
|
||||
}
|
||||
|
||||
@@ -24,11 +24,10 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
|
||||
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
sync::{oneshot, watch},
|
||||
time::{sleep, timeout},
|
||||
@@ -123,8 +122,8 @@ async fn await_initialization(
|
||||
let mut rx = init_state.current.clone();
|
||||
|
||||
let events = async {
|
||||
let e = *rx.borrow();
|
||||
let _ = events.send(e);
|
||||
let e = (*rx.borrow()).clone();
|
||||
let _ = events.send(e).unwrap();
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
let step = *rx.borrow_and_update();
|
||||
@@ -518,10 +517,7 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
wsl_path,
|
||||
resolve_app_path
|
||||
])
|
||||
.events(tauri_specta::collect_events![
|
||||
LoadingWindowComplete,
|
||||
cli::SqliteMigrationProgress
|
||||
])
|
||||
.events(tauri_specta::collect_events![LoadingWindowComplete])
|
||||
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
||||
}
|
||||
|
||||
@@ -561,44 +557,16 @@ async fn initialize(app: AppHandle) {
|
||||
tracing::info!("Main and loading windows created");
|
||||
|
||||
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
|
||||
let sqlite_done_rx = (sqlite_enabled && !sqlite_file_exists()).then(|| {
|
||||
let (sqlite_done_tx, sqlite_done_rx) = oneshot::channel::<()>();
|
||||
tracing::info!(
|
||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||
"Sqlite file not found, waiting for it to be generated"
|
||||
);
|
||||
|
||||
let (done_tx, done_rx) = oneshot::channel::<()>();
|
||||
let done_tx = Arc::new(Mutex::new(Some(done_tx)));
|
||||
|
||||
let init_tx = init_tx.clone();
|
||||
let id = cli::SqliteMigrationProgress::listen(&app, move |e| {
|
||||
let _ = init_tx.send(InitStep::SqliteWaiting);
|
||||
|
||||
if matches!(e.payload, cli::SqliteMigrationProgress::Done)
|
||||
&& let Some(done_tx) = done_tx.lock().unwrap().take()
|
||||
{
|
||||
let _ = done_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(done_rx.map(async move |_| {
|
||||
let _ = sqlite_done_tx.send(());
|
||||
|
||||
app.unlisten(id);
|
||||
}));
|
||||
|
||||
sqlite_done_rx
|
||||
});
|
||||
|
||||
let loading_task = tokio::spawn({
|
||||
let init_tx = init_tx.clone();
|
||||
let app = app.clone();
|
||||
|
||||
async move {
|
||||
let mut sqlite_exists = sqlite_file_exists();
|
||||
|
||||
tracing::info!("Setting up server connection");
|
||||
let server_connection = setup_server_connection(app.clone()).await;
|
||||
tracing::info!("Server connection setup");
|
||||
|
||||
// we delay spawning this future so that the timeout is created lazily
|
||||
let cli_health_check = match server_connection {
|
||||
@@ -654,12 +622,23 @@ async fn initialize(app: AppHandle) {
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("server connection started");
|
||||
|
||||
if let Some(cli_health_check) = cli_health_check {
|
||||
if let Some(sqlite_done_rx) = sqlite_done_rx {
|
||||
let _ = sqlite_done_rx.await;
|
||||
if sqlite_enabled {
|
||||
tracing::debug!(sqlite_exists, "Checking sqlite file existence");
|
||||
if !sqlite_exists {
|
||||
tracing::info!(
|
||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||
"Sqlite file not found, waiting for it to be generated"
|
||||
);
|
||||
let _ = init_tx.send(InitStep::SqliteWaiting);
|
||||
|
||||
while !sqlite_exists {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
sqlite_exists = sqlite_file_exists();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(cli_health_check);
|
||||
}
|
||||
|
||||
@@ -675,11 +654,11 @@ async fn initialize(app: AppHandle) {
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("Loading task timed out, showing loading window");
|
||||
let app = app.clone();
|
||||
let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
Some(loading_window)
|
||||
} else {
|
||||
tracing::debug!("Showing main window without loading window");
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
None
|
||||
@@ -688,6 +667,7 @@ async fn initialize(app: AppHandle) {
|
||||
let _ = loading_task.await;
|
||||
|
||||
tracing::info!("Loading done, completing initialisation");
|
||||
|
||||
let _ = init_tx.send(InitStep::Done);
|
||||
|
||||
if loading_window.is_some() {
|
||||
|
||||
@@ -11,11 +11,17 @@ use crate::{
|
||||
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
|
||||
};
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
|
||||
pub struct WslConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for WslConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||
|
||||
@@ -23,7 +23,6 @@ export const commands = {
|
||||
/** Events */
|
||||
export const events = {
|
||||
loadingWindowComplete: makeEvent<LoadingWindowComplete>("loading-window-complete"),
|
||||
sqliteMigrationProgress: makeEvent<SqliteMigrationProgress>("sqlite-migration-progress"),
|
||||
};
|
||||
|
||||
/* Types */
|
||||
@@ -38,8 +37,6 @@ export type ServerReadyData = {
|
||||
password: string | null,
|
||||
};
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };
|
||||
|
||||
export type WslConfig = {
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import "@opencode-ai/app/index.css"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import "./styles.css"
|
||||
import { createSignal, Match, onCleanup, onMount } from "solid-js"
|
||||
import { createSignal, Match, onMount } from "solid-js"
|
||||
import { commands, events, InitStep } from "./bindings"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { Switch } from "solid-js"
|
||||
@@ -57,29 +57,15 @@ render(() => {
|
||||
"This could take a couple of minutes",
|
||||
]
|
||||
const [textIndex, setTextIndex] = createSignal(0)
|
||||
const [progress, setProgress] = createSignal(0)
|
||||
|
||||
onMount(async () => {
|
||||
const listener = events.sqliteMigrationProgress.listen((e) => {
|
||||
if (e.payload.type === "InProgress") setProgress(e.payload.value)
|
||||
})
|
||||
onCleanup(() => listener.then((c) => c()))
|
||||
|
||||
await new Promise((res) => setTimeout(res, 3000))
|
||||
setTextIndex(1)
|
||||
await new Promise((res) => setTimeout(res, 6000))
|
||||
setTextIndex(2)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span>{textItems[textIndex()]}</span>
|
||||
<span>Progress: {progress()}%</span>
|
||||
<div class="h-2 w-48 rounded-full border border-white relative">
|
||||
<div class="bg-[#fff] h-full absolute left-0 inset-y-0" style={{ width: `${progress()}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <>{textItems[textIndex()]}</>
|
||||
}}
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.79",
|
||||
"@opentui/solid": "0.1.79",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -184,18 +184,6 @@ export namespace Agent {
|
||||
),
|
||||
prompt: PROMPT_TITLE,
|
||||
},
|
||||
handoff: {
|
||||
name: "handoff",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
}),
|
||||
prompt: "none",
|
||||
},
|
||||
summary: {
|
||||
name: "summary",
|
||||
mode: "primary",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32IgnoreCtrlC, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
@@ -111,18 +110,8 @@ export function tui(input: {
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
win32IgnoreCtrlC()
|
||||
|
||||
const mode = await getTerminalBackgroundColor()
|
||||
|
||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const onExit = async () => {
|
||||
unguard?.()
|
||||
await input.onExit?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ function init() {
|
||||
},
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
if (option.disabled) return []
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
return {
|
||||
|
||||
@@ -7,27 +7,6 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pickLatest(models: [string, Provider["models"][string]][]) {
|
||||
const picks: Record<string, [string, Provider["models"][string]]> = {}
|
||||
for (const item of models) {
|
||||
const model = item[0]
|
||||
const info = item[1]
|
||||
const key = info.family ?? model
|
||||
const prev = picks[key]
|
||||
if (!prev) {
|
||||
picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (info.release_date !== prev[1].release_date) {
|
||||
if (info.release_date > prev[1].release_date) picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (model > prev[0]) picks[key] = item
|
||||
}
|
||||
return Object.values(picks)
|
||||
}
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -42,7 +21,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [all, setAll] = createSignal(false)
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -94,8 +72,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) => {
|
||||
const items = pipe(
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
@@ -126,9 +104,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
)
|
||||
return items
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -177,13 +154,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_show_all_toggle?.[0],
|
||||
title: all() ? "Show latest only" : "Show all models",
|
||||
onTrigger: () => {
|
||||
setAll((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
mode?: "normal" | "shell" | "handoff"
|
||||
mode?: "normal" | "shell"
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
|
||||
@@ -120,7 +120,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell" | "handoff"
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
interrupt: number
|
||||
placeholder: number
|
||||
@@ -349,20 +349,6 @@ export function Prompt(props: PromptProps) {
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Handoff",
|
||||
value: "prompt.handoff",
|
||||
disabled: props.sessionID === undefined,
|
||||
category: "Prompt",
|
||||
slash: {
|
||||
name: "handoff",
|
||||
},
|
||||
onSelect: () => {
|
||||
input.clear()
|
||||
setStore("mode", "handoff")
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -540,45 +526,17 @@ export function Prompt(props: PromptProps) {
|
||||
async function submit() {
|
||||
if (props.disabled) return
|
||||
if (autocomplete?.visible) return
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
promptModelWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if (store.mode === "handoff") {
|
||||
const result = await sdk.client.session.handoff({
|
||||
sessionID: props.sessionID!,
|
||||
goal: store.prompt.input,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
modelID: selectedModel.modelID,
|
||||
},
|
||||
})
|
||||
if (result.data) {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
initialPrompt: {
|
||||
input: result.data.text,
|
||||
parts:
|
||||
result.data.files.map((file) => ({
|
||||
type: "file",
|
||||
url: file,
|
||||
filename: file,
|
||||
mime: "text/plain",
|
||||
})) ?? [],
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.prompt.input) return
|
||||
const trimmed = store.prompt.input.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
promptModelWarning()
|
||||
return
|
||||
}
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
@@ -779,7 +737,6 @@ export function Prompt(props: PromptProps) {
|
||||
const highlight = createMemo(() => {
|
||||
if (keybind.leader) return theme.border
|
||||
if (store.mode === "shell") return theme.primary
|
||||
if (store.mode === "handoff") return theme.warning
|
||||
return local.agent.color(local.agent.current().name)
|
||||
})
|
||||
|
||||
@@ -791,7 +748,6 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (store.mode === "handoff") return "Goal for the new session"
|
||||
if (props.sessionID) return undefined
|
||||
if (store.mode === "shell") {
|
||||
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
|
||||
@@ -919,7 +875,7 @@ export function Prompt(props: PromptProps) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (store.mode === "shell" || store.mode === "handoff") {
|
||||
if (store.mode === "shell") {
|
||||
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
||||
setStore("mode", "normal")
|
||||
e.preventDefault()
|
||||
@@ -1040,11 +996,7 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>{Locale.titlecase(local.agent.current().name)}</Match>
|
||||
<Match when={store.mode === "shell"}>Shell</Match>
|
||||
<Match when={store.mode === "handoff"}>Handoff</Match>
|
||||
</Switch>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -1191,11 +1143,6 @@ export function Prompt(props: PromptProps) {
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "handoff"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit handoff mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../component/prompt/history"
|
||||
|
||||
@@ -32,7 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(reconcile(route))
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Log } from "@/util/log"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -78,14 +77,6 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
// (Important when running under `bun run` wrappers on Windows.)
|
||||
win32InstallCtrlCGuard()
|
||||
|
||||
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
|
||||
// spawn or async work so the OS cannot kill the process group.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { dlopen, ptr } from "bun:ffi"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell Windows to ignore CTRL_C_EVENT for this process.
|
||||
*
|
||||
* SetConsoleCtrlHandler(NULL, TRUE) makes the process ignore Ctrl+C
|
||||
* signals at the OS level. Belt-and-suspenders alongside disabling
|
||||
* ENABLE_PROCESSED_INPUT.
|
||||
*/
|
||||
export function win32IgnoreCtrlC() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
k32!.symbols.SetConsoleCtrlHandler(null, 1)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as any
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
if (typeof original === "function") {
|
||||
stdin.setRawMode = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
|
||||
unhook = () => {
|
||||
clearInterval(interval)
|
||||
if (typeof original === "function") {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
@@ -778,7 +778,6 @@ export namespace Config {
|
||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
@@ -23,7 +23,6 @@ import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { win32DisableProcessedInput, win32IgnoreCtrlC } from "./cli/cmd/tui/win32"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
import { SessionCommand } from "./cli/cmd/session"
|
||||
@@ -44,14 +43,6 @@ process.on("uncaughtException", (e) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Disable Windows CTRL_C_EVENT as early as possible. When running under
|
||||
// `bun run` (e.g. `bun dev`), the parent bun process shares this console
|
||||
// and would be killed by the OS before any JS signal handler fires.
|
||||
win32DisableProcessedInput()
|
||||
// Belt-and-suspenders: even if something re-enables ENABLE_PROCESSED_INPUT
|
||||
// later (opentui raw mode, libuv, etc.), ignore the generated event.
|
||||
win32IgnoreCtrlC()
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
@@ -110,10 +101,11 @@ const cli = yargs(hideBin(process.argv))
|
||||
process.stdout.write(
|
||||
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
|
||||
)
|
||||
if (event.current === event.total) process.stdout.write("\n")
|
||||
} else {
|
||||
console.log(`sqlite-migration:${percent}`)
|
||||
process.stdout.write(`\rsqlite-migration:${percent}`)
|
||||
}
|
||||
|
||||
if (event.current === event.total) process.stdout.write("\n")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -571,28 +571,25 @@ export namespace MCP {
|
||||
const clientsSnapshot = await clients()
|
||||
const defaultTimeout = cfg.experimental?.mcp_timeout
|
||||
|
||||
const connectedClients = Object.entries(clientsSnapshot).filter(
|
||||
([clientName]) => s.status[clientName]?.status === "connected",
|
||||
)
|
||||
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
||||
// Only include tools from connected MCPs (skip disabled ones)
|
||||
if (s.status[clientName]?.status !== "connected") {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolsResults = await Promise.all(
|
||||
connectedClients.map(async ([clientName, client]) => {
|
||||
const toolsResult = await client.listTools().catch((e) => {
|
||||
log.error("failed to get tools", { clientName, error: e.message })
|
||||
const failedStatus = {
|
||||
status: "failed" as const,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
s.status[clientName] = failedStatus
|
||||
delete s.clients[clientName]
|
||||
return undefined
|
||||
})
|
||||
return { clientName, client, toolsResult }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const { clientName, client, toolsResult } of toolsResults) {
|
||||
if (!toolsResult) continue
|
||||
const toolsResult = await client.listTools().catch((e) => {
|
||||
log.error("failed to get tools", { clientName, error: e.message })
|
||||
const failedStatus = {
|
||||
status: "failed" as const,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
s.status[clientName] = failedStatus
|
||||
delete s.clients[clientName]
|
||||
return undefined
|
||||
})
|
||||
if (!toolsResult) {
|
||||
continue
|
||||
}
|
||||
const mcpConfig = config[clientName]
|
||||
const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
||||
const timeout = entry?.timeout ?? defaultTimeout
|
||||
|
||||
@@ -7,7 +7,6 @@ import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { SessionHandoff } from "../../session/handoff"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
@@ -933,41 +932,5 @@ export const SessionRoutes = lazy(() =>
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/handoff",
|
||||
describeRoute({
|
||||
summary: "Handoff session",
|
||||
description: "Extract context and relevant files for another agent to continue the conversation.",
|
||||
operationId: "session.handoff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Handoff data extracted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ text: z.string(), files: z.string().array() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionHandoff.handoff.schema.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const body = c.req.valid("json")
|
||||
const result = await SessionHandoff.handoff({
|
||||
sessionID: params.sessionID,
|
||||
model: body.model,
|
||||
goal: body.goal,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -41,9 +41,7 @@ export namespace SessionCompaction {
|
||||
|
||||
const reserved =
|
||||
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
const usable = input.model.limit.input
|
||||
? input.model.limit.input - reserved
|
||||
: context - ProviderTransform.maxOutputTokens(input.model)
|
||||
const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved
|
||||
return count >= usable
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { fn } from "@/util/fn"
|
||||
import z from "zod"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { LLM } from "./llm"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Identifier } from "@/id/id"
|
||||
import PROMPT_HANDOFF from "./prompt/handoff.txt"
|
||||
import { type Tool } from "ai"
|
||||
import { SessionStatus } from "./status"
|
||||
import { defer } from "@/util/defer"
|
||||
|
||||
export namespace SessionHandoff {
|
||||
const HandoffTool: Tool = {
|
||||
description:
|
||||
"A tool to extract relevant information from the thread and select relevant files for another agent to continue the conversation. Use this tool to identify the most important context and files needed.",
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe(PROMPT_HANDOFF),
|
||||
files: z
|
||||
.string()
|
||||
.array()
|
||||
.describe(
|
||||
[
|
||||
"An array of file or directory paths (workspace-relative) that are relevant to accomplishing the goal.",
|
||||
"",
|
||||
'IMPORTANT: Return as a JSON array of strings, e.g., ["packages/core/src/session/message-v2.ts", "packages/core/src/session/prompt/handoff.txt"]',
|
||||
"",
|
||||
"Rules:",
|
||||
"- Maximum 10 files. Only include the most critical files needed for the task.",
|
||||
"- You can include directories if multiple files from that directory are needed",
|
||||
"- Prioritize by importance and relevance. PUT THE MOST IMPORTANT FILES FIRST.",
|
||||
'- Return workspace-relative paths (e.g., "packages/core/src/session/message-v2.ts")',
|
||||
"- Do not use absolute paths or invent files",
|
||||
].join("\n"),
|
||||
),
|
||||
}),
|
||||
async execute(_args, _ctx) {
|
||||
return {}
|
||||
},
|
||||
}
|
||||
|
||||
export const handoff = fn(
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
model: z.object({ providerID: z.string(), modelID: z.string() }),
|
||||
goal: z.string().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
SessionStatus.set(input.sessionID, { type: "busy" })
|
||||
using _ = defer(() => SessionStatus.set(input.sessionID, { type: "idle" }))
|
||||
const messages = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID))
|
||||
const agent = await Agent.get("handoff")
|
||||
const model = await iife(async () => {
|
||||
if (agent.model) return Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
const small = await Provider.getSmallModel(input.model.providerID)
|
||||
if (small) return small
|
||||
return Provider.getModel(input.model.providerID, input.model.modelID)
|
||||
})
|
||||
const user = {
|
||||
info: {
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
},
|
||||
agent: agent.name,
|
||||
sessionID: input.sessionID,
|
||||
id: Identifier.ascending("user"),
|
||||
role: "user",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
} satisfies MessageV2.User,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_HANDOFF + "\n\nMy request:\n" + (input.goal ?? "general summarization"),
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.sessionID,
|
||||
messageID: Identifier.ascending("message"),
|
||||
},
|
||||
] satisfies MessageV2.TextPart[],
|
||||
} satisfies MessageV2.WithParts
|
||||
const abort = new AbortController()
|
||||
const stream = await LLM.stream({
|
||||
agent,
|
||||
messages: MessageV2.toModelMessages([...messages, user], model),
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
model,
|
||||
system: [],
|
||||
small: true,
|
||||
user: user.info,
|
||||
output: "tool",
|
||||
tools: {
|
||||
handoff: HandoffTool,
|
||||
},
|
||||
})
|
||||
|
||||
const [result] = await stream.toolCalls
|
||||
if (!result) throw new Error("Handoff tool did not return a result")
|
||||
return result.input
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,6 @@ export namespace LLM {
|
||||
small?: boolean
|
||||
tools: Record<string, Tool>
|
||||
retries?: number
|
||||
output?: "tool"
|
||||
}
|
||||
|
||||
export type StreamOutput = StreamTextResult<ToolSet, unknown>
|
||||
@@ -208,7 +207,6 @@ export namespace LLM {
|
||||
tools,
|
||||
maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
toolChoice: input.output === "tool" ? "required" : undefined,
|
||||
headers: {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
|
||||
@@ -1020,9 +1020,9 @@ export namespace SessionPrompt {
|
||||
}
|
||||
}
|
||||
}
|
||||
offset = Math.max(start, 1)
|
||||
offset = Math.max(start - 1, 0)
|
||||
if (end) {
|
||||
limit = end - (offset - 1)
|
||||
limit = end - offset
|
||||
}
|
||||
}
|
||||
const args = { filePath: filepath, offset, limit }
|
||||
@@ -1233,7 +1233,33 @@ export namespace SessionPrompt {
|
||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||
if (!userMessage) return input.messages
|
||||
|
||||
// Plan mode logic
|
||||
// Original logic when experimental plan mode is disabled
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
|
||||
if (input.agent.name === "plan") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
||||
if (wasPlan && input.agent.name === "build") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: BUILD_SWITCH,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
return input.messages
|
||||
}
|
||||
|
||||
// New plan mode logic when flag is enabled
|
||||
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
||||
|
||||
// Switching from plan mode to build mode
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
Extract relevant context from the conversation above for continuing this work. Write from my perspective (first person: "I did...", "I told you...").
|
||||
|
||||
Consider what would be useful to know based on my request below. Questions that might be relevant:
|
||||
|
||||
- What did I just do or implement?
|
||||
- What instructions did I already give you which are still relevant (e.g. follow patterns in the codebase)?
|
||||
- What files did I already tell you that's important or that I am working on (and should continue working on)?
|
||||
- Did I provide a plan or spec that should be included?
|
||||
- What did I already tell you that's important (certain libraries, patterns, constraints, preferences)?
|
||||
- What important technical details did I discover (APIs, methods, patterns)?
|
||||
- What caveats, limitations, or open questions did I find?
|
||||
|
||||
Extract what matters for the specific request below. Don't answer questions that aren't relevant. Pick an appropriate length based on the complexity of the request.
|
||||
|
||||
Focus on capabilities and behavior, not file-by-file changes. Avoid excessive implementation details (variable names, storage keys, constants) unless critical.
|
||||
|
||||
Format: Plain text with bullets. No markdown headers, no bold/italic, no code fences. Use workspace-relative paths for files.
|
||||
@@ -2,9 +2,9 @@ Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
||||
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||
- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
@@ -17,26 +17,19 @@ const MAX_BYTES = 50 * 1024
|
||||
export const ReadTool = Tool.define("read", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
||||
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
|
||||
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
|
||||
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (params.offset !== undefined && params.offset < 1) {
|
||||
throw new Error("offset must be greater than or equal to 1")
|
||||
}
|
||||
let filepath = params.filePath
|
||||
if (!path.isAbsolute(filepath)) {
|
||||
filepath = path.resolve(Instance.directory, filepath)
|
||||
}
|
||||
const title = path.relative(Instance.worktree, filepath)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
const stat = await file.stat().catch(() => undefined)
|
||||
|
||||
await assertExternalDirectory(ctx, filepath, {
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||
kind: stat?.isDirectory() ? "directory" : "file",
|
||||
})
|
||||
|
||||
await ctx.ask({
|
||||
@@ -46,7 +39,8 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (!stat) {
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
const dir = path.dirname(filepath)
|
||||
const base = path.basename(filepath)
|
||||
|
||||
@@ -66,48 +60,6 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
|
||||
const entries = await Promise.all(
|
||||
dirents.map(async (dirent) => {
|
||||
if (dirent.isDirectory()) return dirent.name + "/"
|
||||
if (dirent.isSymbolicLink()) {
|
||||
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
||||
if (target?.isDirectory()) return dirent.name + "/"
|
||||
}
|
||||
return dirent.name
|
||||
}),
|
||||
)
|
||||
entries.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset ?? 1
|
||||
const start = offset - 1
|
||||
const sliced = entries.slice(start, start + limit)
|
||||
const truncated = start + sliced.length < entries.length
|
||||
|
||||
const output = [
|
||||
`<path>${filepath}</path>`,
|
||||
`<type>directory</type>`,
|
||||
`<entries>`,
|
||||
sliced.join("\n"),
|
||||
truncated
|
||||
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
||||
: `\n(${entries.length} entries)`,
|
||||
`</entries>`,
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview: sliced.slice(0, 20).join("\n"),
|
||||
truncated,
|
||||
loaded: [] as string[],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
@@ -123,7 +75,7 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
loaded: instructions.map((i) => i.filepath),
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
@@ -142,15 +94,13 @@ export const ReadTool = Tool.define("read", {
|
||||
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
||||
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset ?? 1
|
||||
const start = offset - 1
|
||||
const offset = params.offset || 0
|
||||
const lines = await file.text().then((text) => text.split("\n"))
|
||||
if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`)
|
||||
|
||||
const raw: string[] = []
|
||||
let bytes = 0
|
||||
let truncatedByBytes = false
|
||||
for (let i = start; i < Math.min(lines.length, start + limit); i++) {
|
||||
for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
|
||||
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
|
||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
@@ -162,15 +112,15 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
|
||||
const content = raw.map((line, index) => {
|
||||
return `${index + offset}: ${line}`
|
||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
|
||||
})
|
||||
const preview = raw.slice(0, 20).join("\n")
|
||||
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
let output = "<file>\n"
|
||||
output += content.join("\n")
|
||||
|
||||
const totalLines = lines.length
|
||||
const lastReadLine = offset + raw.length - 1
|
||||
const lastReadLine = offset + raw.length
|
||||
const hasMoreLines = totalLines > lastReadLine
|
||||
const truncated = hasMoreLines || truncatedByBytes
|
||||
|
||||
@@ -181,7 +131,7 @@ export const ReadTool = Tool.define("read", {
|
||||
} else {
|
||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||
}
|
||||
output += "\n</content>"
|
||||
output += "\n</file>"
|
||||
|
||||
// just warms the lsp client
|
||||
LSP.touchFile(filepath, false)
|
||||
@@ -197,7 +147,7 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
loaded: instructions.map((i) => i.filepath),
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
Read a file or directory from the local filesystem. If the path does not exist, an error is returned.
|
||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The filePath parameter should be an absolute path.
|
||||
- By default, this tool returns up to 2000 lines from the start of the file.
|
||||
- The offset parameter is the line number to start from (1-indexed).
|
||||
- To read later sections, call this tool again with a larger offset.
|
||||
- Use the grep tool to find specific content in large files or files with long lines.
|
||||
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
|
||||
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||
- Any line longer than 2000 characters is truncated.
|
||||
- Call this tool in parallel when you know there are multiple files you want to read.
|
||||
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
|
||||
- This tool can read image files and PDFs and return them as file attachments.
|
||||
- The filePath parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
- You can read image files using this tool.
|
||||
|
||||
@@ -114,7 +114,7 @@ export namespace ToolRegistry {
|
||||
ApplyPatchTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,32 +78,6 @@ describe("tool.read external_directory permission", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "external", "a.txt"), "a")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when reading relative path outside project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
@@ -258,7 +232,7 @@ describe("tool.read truncation", () => {
|
||||
test("respects offset parameter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
|
||||
await Bun.write(path.join(dir, "offset.txt"), lines)
|
||||
},
|
||||
})
|
||||
@@ -275,43 +249,6 @@ describe("tool.read truncation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when offset is beyond end of file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
|
||||
await Bun.write(path.join(dir, "short.txt"), lines)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(
|
||||
read.execute({ filePath: path.join(tmp.path, "short.txt"), offset: 4, limit: 5 }, ctx),
|
||||
).rejects.toThrow("Offset 4 is out of range for this file (3 lines)")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not mark final directory page as truncated", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 6, limit: 5 }, ctx)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).not.toContain("Showing 5 of 10 entries")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("truncates long lines", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -110,8 +110,6 @@ import type {
|
||||
SessionForkResponses,
|
||||
SessionGetErrors,
|
||||
SessionGetResponses,
|
||||
SessionHandoffErrors,
|
||||
SessionHandoffResponses,
|
||||
SessionInitErrors,
|
||||
SessionInitResponses,
|
||||
SessionListResponses,
|
||||
@@ -1768,48 +1766,6 @@ export class Session extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff session
|
||||
*
|
||||
* Extract context and relevant files for another agent to continue the conversation.
|
||||
*/
|
||||
public handoff<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
goal?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "body", key: "model" },
|
||||
{ in: "body", key: "goal" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<SessionHandoffResponses, SessionHandoffErrors, ThrowOnError>({
|
||||
url: "/session/{sessionID}/handoff",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Part extends HeyApiClient {
|
||||
|
||||
@@ -1039,10 +1039,6 @@ export type KeybindsConfig = {
|
||||
* Toggle model favorite status
|
||||
*/
|
||||
model_favorite_toggle?: string
|
||||
/**
|
||||
* Toggle showing all models
|
||||
*/
|
||||
model_show_all_toggle?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
@@ -3840,51 +3836,6 @@ export type PermissionRespondResponses = {
|
||||
|
||||
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
|
||||
|
||||
export type SessionHandoffData = {
|
||||
body?: {
|
||||
model: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
goal?: string
|
||||
}
|
||||
path: {
|
||||
/**
|
||||
* Session ID
|
||||
*/
|
||||
sessionID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/session/{sessionID}/handoff"
|
||||
}
|
||||
|
||||
export type SessionHandoffErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SessionHandoffError = SessionHandoffErrors[keyof SessionHandoffErrors]
|
||||
|
||||
export type SessionHandoffResponses = {
|
||||
/**
|
||||
* Handoff data extracted
|
||||
*/
|
||||
200: {
|
||||
text: string
|
||||
files: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionHandoffResponse = SessionHandoffResponses[keyof SessionHandoffResponses]
|
||||
|
||||
export type PermissionReplyData = {
|
||||
body?: {
|
||||
reply: "once" | "always" | "reject"
|
||||
|
||||
@@ -3297,108 +3297,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session/{sessionID}/handoff": {
|
||||
"post": {
|
||||
"operationId": "session.handoff",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "sessionID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"description": "Session ID"
|
||||
}
|
||||
],
|
||||
"summary": "Handoff session",
|
||||
"description": "Extract context and relevant files for another agent to continue the conversation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Handoff data extracted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["text", "files"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "modelID"]
|
||||
},
|
||||
"goal": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["model"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.handoff({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/permission/{requestID}/reply": {
|
||||
"post": {
|
||||
"operationId": "permission.reply",
|
||||
|
||||
@@ -600,3 +600,4 @@ These environment variables enable experimental features that may change or be r
|
||||
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features |
|
||||
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable experimental LSP type checking |
|
||||
| `OPENCODE_EXPERIMENTAL_MARKDOWN` | boolean | Enable experimental markdown features |
|
||||
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode |
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
## Payload limits
|
||||
|
||||
Prevent blocking storage writes and runaway persisted size
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Large payloads (base64 images, terminal buffers) are currently persisted inside key-value stores:
|
||||
|
||||
- web: `localStorage` (sync, blocks the main thread)
|
||||
- desktop: Tauri Store-backed async storage files (still expensive when values are huge)
|
||||
|
||||
We’ll introduce size-aware persistence policies plus a dedicated “blob store” for large/binary data (IndexedDB on web; separate files on desktop). Prompt/history state will persist only lightweight references to blobs and load them on demand.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Stop persisting image `dataUrl` blobs inside web `localStorage`
|
||||
- Stop persisting image `dataUrl` blobs inside desktop store `.dat` files
|
||||
- Store image payloads out-of-band (blob store) and load lazily when needed (e.g. when restoring a history item)
|
||||
- Prevent terminal buffer persistence from exceeding safe size limits
|
||||
- Keep persistence behavior predictable across web (sync) and desktop (async)
|
||||
- Provide escape hatches via flags and per-key size caps
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Cross-device sync of images or terminal buffers
|
||||
- Lossless persistence of full terminal scrollback on web
|
||||
- Perfect blob deduplication or a complex reference-counting system on day one
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- `packages/app/src/utils/persist.ts` uses `localStorage` (sync) on web and async storage only on desktop.
|
||||
- Desktop storage is implemented via `@tauri-apps/plugin-store` and writes to named `.dat` files (see `packages/desktop/src/index.tsx`). Large values bloat these files and increase flush costs.
|
||||
- Prompt history persists under `Persist.global("prompt-history")` (`packages/app/src/components/prompt-input.tsx`) and can include image parts (`dataUrl`).
|
||||
- Prompt draft persistence uses `packages/app/src/context/prompt.tsx` and can also include image parts (`dataUrl`).
|
||||
- Terminal buffer is serialized in `packages/app/src/components/terminal.tsx` and persisted in `packages/app/src/context/terminal.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
#### 1) Add per-key persistence policies (KV store guardrails)
|
||||
|
||||
In `packages/app/src/utils/persist.ts`, add policy hooks for each persisted key:
|
||||
|
||||
- `warnBytes` (soft warning threshold)
|
||||
- `maxBytes` (hard cap)
|
||||
- `transformIn` / `transformOut` for lossy persistence (e.g. strip or refactor fields)
|
||||
- `onOversize` strategy: `drop`, `truncate`, or `migrateToBlobRef`
|
||||
|
||||
This protects both:
|
||||
|
||||
- web (`localStorage` is sync)
|
||||
- desktop (async, but still expensive to store/flush giant values)
|
||||
|
||||
#### 2) Add a dedicated blob store for large data
|
||||
|
||||
Introduce a small blob-store abstraction used by the app layer:
|
||||
|
||||
- web backend: IndexedDB (store `Blob` values keyed by `id`)
|
||||
- desktop backend: filesystem directory under the app data directory (store one file per blob)
|
||||
|
||||
Store _references_ to blobs inside the persisted JSON instead of the blob contents.
|
||||
|
||||
#### 3) Persist image parts as references (not base64 payloads)
|
||||
|
||||
Update the prompt image model so the in-memory shape can still use a `dataUrl` for UI, but the persisted representation is reference-based.
|
||||
|
||||
Suggested approach:
|
||||
|
||||
- Keep `ImageAttachmentPart` with:
|
||||
- required: `id`, `filename`, `mime`
|
||||
- optional/ephemeral: `dataUrl?: string`
|
||||
- new: `blobID?: string` (or `ref: string`)
|
||||
|
||||
Persistence rules:
|
||||
|
||||
- When writing persisted prompt/history state:
|
||||
- ensure each image part is stored in blob store (`blobID`)
|
||||
- persist only metadata + `blobID` (no `dataUrl`)
|
||||
- When reading persisted prompt/history state:
|
||||
- do not eagerly load blob payloads
|
||||
- hydrate `dataUrl` only when needed:
|
||||
- when applying a history entry into the editor
|
||||
- before submission (ensure all image parts have usable `dataUrl`)
|
||||
- when rendering an attachment preview, if required
|
||||
|
||||
---
|
||||
|
||||
### Phased implementation steps
|
||||
|
||||
1. Add guardrails in `persist.ts`
|
||||
|
||||
- Implement size estimation in `packages/app/src/utils/persist.ts` using `TextEncoder` byte length on JSON strings.
|
||||
- Add a policy registry keyed by persist name (e.g. `"prompt-history"`, `"prompt"`, `"terminal"`).
|
||||
- Add a feature flag (e.g. `persist.payloadLimits`) to enable enforcement gradually.
|
||||
|
||||
2. Add blob-store abstraction + platform hooks
|
||||
|
||||
- Add a new app-level module (e.g. `packages/app/src/utils/blob.ts`) defining:
|
||||
- `put(id, bytes|Blob)`
|
||||
- `get(id)`
|
||||
- `remove(id)`
|
||||
- Extend the `Platform` interface (`packages/app/src/context/platform.tsx`) with optional blob methods, or provide a default web implementation and override on desktop:
|
||||
- web: implement via IndexedDB
|
||||
- desktop: implement via filesystem files (requires adding a Tauri fs plugin or `invoke` wrappers)
|
||||
|
||||
3. Update prompt history + prompt draft persistence to use blob refs
|
||||
|
||||
- Update prompt/history serialization paths to ensure image parts are stored as blob refs:
|
||||
- Prompt history: `packages/app/src/components/prompt-input.tsx`
|
||||
- Prompt draft: `packages/app/src/context/prompt.tsx`
|
||||
- Ensure “apply history prompt” hydrates image blobs only when applying the prompt (not during background load).
|
||||
|
||||
4. One-time migration for existing persisted base64 images
|
||||
|
||||
- On read, detect legacy persisted image parts that include `dataUrl`.
|
||||
- If a `dataUrl` is found:
|
||||
- write it into the blob store (convert dataUrl → bytes)
|
||||
- replace persisted payload with `{ blobID, filename, mime, id }` only
|
||||
- re-save the reduced version
|
||||
- If migration fails (missing permissions, quota, etc.), fall back to:
|
||||
- keep the prompt entry but drop the image payload and mark as unavailable
|
||||
|
||||
5. Fix terminal persistence (bounded snapshot)
|
||||
|
||||
- In `packages/app/src/context/terminal.tsx`, persist only:
|
||||
- last `maxLines` and/or
|
||||
- last `maxBytes` of combined text
|
||||
- In `packages/app/src/components/terminal.tsx`, keep the full in-memory buffer unchanged.
|
||||
|
||||
6. Add basic blob lifecycle cleanup
|
||||
To avoid “blob directory grows forever”, add one of:
|
||||
|
||||
- TTL-based cleanup: store `lastAccessed` per blob and delete blobs older than N days
|
||||
- Reference scan cleanup: periodically scan prompt-history + prompt drafts, build a set of referenced `blobID`s, and delete unreferenced blobs
|
||||
|
||||
Start with TTL-based cleanup (simpler, fewer cross-store dependencies), then consider scan-based cleanup if needed.
|
||||
|
||||
---
|
||||
|
||||
### Data migration / backward compatibility
|
||||
|
||||
- KV store data:
|
||||
- policies should be tolerant of missing fields (e.g. `dataUrl` missing)
|
||||
- Image parts:
|
||||
- treat missing `dataUrl` as “not hydrated yet”
|
||||
- treat missing `blobID` (legacy) as “not persisted” or “needs migration”
|
||||
- Desktop:
|
||||
- blob files should be namespaced (e.g. `opencode/blobs/<blobID>`) to avoid collisions
|
||||
|
||||
---
|
||||
|
||||
### Risk + mitigations
|
||||
|
||||
- Risk: blob store is unavailable (IndexedDB disabled, desktop fs permissions).
|
||||
- Mitigation: keep base state functional; persist prompts without image payloads and show a clear placeholder.
|
||||
- Risk: lazy hydration introduces edge cases when submitting.
|
||||
- Mitigation: add a pre-submit “ensure images hydrated” step; if hydration fails, block submission with a clear error or submit without images.
|
||||
- Risk: dataUrl→bytes conversion cost during migration.
|
||||
- Mitigation: migrate incrementally (only when reading an entry) and/or use `requestIdleCallback` on web.
|
||||
- Risk: blob cleanup deletes blobs still needed.
|
||||
- Mitigation: TTL default should be conservative; scan-based cleanup should only delete blobs unreferenced by current persisted state.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Unit-level:
|
||||
- size estimation + policy enforcement in `persist.ts`
|
||||
- blob store put/get/remove round trips (web + desktop backends)
|
||||
- Manual scenarios:
|
||||
- attach multiple images, reload, and confirm:
|
||||
- KV store files do not balloon
|
||||
- images can be restored when selecting history items
|
||||
- open terminal with large output and confirm reload restores bounded snapshot quickly
|
||||
- confirm prompt draft persistence still works in `packages/app/src/context/prompt.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Rollout plan
|
||||
|
||||
- Phase 1: ship with `persist.payloadLimits` off; log oversize detections in dev.
|
||||
- Phase 2: enable image blob refs behind `persist.imageBlobs` (web + desktop).
|
||||
- Phase 3: enable terminal truncation and enforce hard caps for known hot keys.
|
||||
- Phase 4: enable blob cleanup behind `persist.blobGc` (TTL first).
|
||||
- Provide quick kill switches by disabling each flag independently.
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- What should the canonical persisted image schema be (`blobID` field name, placeholder shape, etc.)?
|
||||
- Desktop implementation detail:
|
||||
- add `@tauri-apps/plugin-fs` vs custom `invoke()` commands for blob read/write?
|
||||
- where should blob files live (appDataDir) and what retention policy is acceptable?
|
||||
- Web implementation detail:
|
||||
- do we store `Blob` directly in IndexedDB, or store base64 strings?
|
||||
- Should prompt-history images be retained indefinitely, or only for the last `MAX_HISTORY` entries?
|
||||
@@ -1,141 +0,0 @@
|
||||
## Cache eviction
|
||||
|
||||
Add explicit bounds for long-lived in-memory state
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Several in-memory caches grow without limits during long sessions. We’ll introduce explicit eviction (LRU + TTL + size caps) for sessions/messages/file contents and global per-directory sync stores.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Prevent unbounded memory growth from caches that survive navigation
|
||||
- Add consistent eviction primitives shared across contexts
|
||||
- Keep UI responsive under heavy usage (many sessions, large files)
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Perfect cache hit rates or prefetch strategies
|
||||
- Changing server APIs or adding background jobs
|
||||
- Persisting caches for offline use
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Global sync uses per-directory child stores without eviction in `packages/app/src/context/global-sync.tsx`.
|
||||
- File contents cached in `packages/app/src/context/file.tsx` with no cap.
|
||||
- Session-heavy pages include `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
- Introduce a shared cache utility that supports:
|
||||
- `maxEntries`, `maxBytes` (approx), and `ttlMs`
|
||||
- LRU ordering with explicit `touch(key)` on access
|
||||
- deterministic `evict()` and `clear()` APIs
|
||||
- Apply the utility to:
|
||||
- global-sync per-directory child stores (cap number of directories kept “hot”)
|
||||
- file contents cache (cap by entries + bytes, with TTL)
|
||||
- session/message caches (cap by session count, and optionally message count)
|
||||
- Add feature flags per cache domain to allow partial rollout (e.g. `cache.eviction.files`).
|
||||
|
||||
---
|
||||
|
||||
### Phased implementation steps
|
||||
|
||||
1. Add a generic cache helper
|
||||
|
||||
- Create `packages/app/src/utils/cache.ts` with a small, dependency-free LRU+TTL.
|
||||
- Keep it framework-agnostic and usable from Solid contexts.
|
||||
|
||||
Sketch:
|
||||
|
||||
```ts
|
||||
type CacheOpts = {
|
||||
maxEntries: number
|
||||
ttlMs?: number
|
||||
maxBytes?: number
|
||||
sizeOf?: (value: unknown) => number
|
||||
}
|
||||
|
||||
function createLruCache<T>(opts: CacheOpts) {
|
||||
// get, set, delete, clear, evictExpired, stats
|
||||
}
|
||||
```
|
||||
|
||||
2. Apply eviction to file contents
|
||||
|
||||
- In `packages/app/src/context/file.tsx`:
|
||||
- wrap the existing file-content map in the LRU helper
|
||||
- approximate size via `TextEncoder` length of content strings
|
||||
- evict on `set` and periodically via `requestIdleCallback` when available
|
||||
- Add a small TTL (e.g. 10–30 minutes) to discard stale contents.
|
||||
|
||||
3. Apply eviction to global-sync child stores
|
||||
|
||||
- In `packages/app/src/context/global-sync.tsx`:
|
||||
- track child stores by directory key in an LRU with `maxEntries`
|
||||
- call a `dispose()` hook on eviction to release subscriptions and listeners
|
||||
- Ensure “currently active directory” is always `touch()`’d to avoid surprise evictions.
|
||||
|
||||
4. Apply eviction to session/message caches
|
||||
|
||||
- Identify the session/message caching touchpoints used by `packages/app/src/pages/session.tsx`.
|
||||
- Add caps that reflect UI needs (e.g. last 10–20 sessions kept, last N messages per session if cached).
|
||||
|
||||
5. Add developer tooling
|
||||
|
||||
- Add a debug-only stats readout (console or dev panel) for cache sizes and eviction counts.
|
||||
- Add a one-click “clear caches” action for troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
### Data migration / backward compatibility
|
||||
|
||||
- No persisted schema changes are required since this targets in-memory caches.
|
||||
- If any cache is currently mirrored into persistence, keep keys stable and only change in-memory retention.
|
||||
|
||||
---
|
||||
|
||||
### Risk + mitigations
|
||||
|
||||
- Risk: evicting content still needed causes extra refetches and flicker.
|
||||
- Mitigation: always pin “active” entities and evict least-recently-used first.
|
||||
- Risk: disposing global-sync child stores could leak listeners if not cleaned up correctly.
|
||||
- Mitigation: require an explicit `dispose()` contract and add dev assertions for listener counts.
|
||||
- Risk: approximate byte sizing is imprecise.
|
||||
- Mitigation: combine entry caps with byte caps and keep thresholds conservative.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Add tests for `createLruCache` covering TTL expiry, LRU ordering, and eviction triggers.
|
||||
- Manual scenarios:
|
||||
- open many files and confirm memory stabilizes and UI remains responsive
|
||||
- switch across many directories and confirm global-sync does not continuously grow
|
||||
- long session navigation loop and confirm caches plateau
|
||||
|
||||
---
|
||||
|
||||
### Rollout plan
|
||||
|
||||
- Land cache utility first with flags default off.
|
||||
- Enable file cache eviction first (lowest behavioral risk).
|
||||
- Enable global-sync eviction next with conservative caps and strong logging in dev.
|
||||
- Enable session/message eviction last after observing real usage patterns.
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- What are the current session/message cache structures and their ownership boundaries?
|
||||
- Which child stores in `global-sync.tsx` have resources that must be disposed explicitly?
|
||||
- What caps are acceptable for typical workflows (files open, directories visited, sessions viewed)?
|
||||
@@ -1,145 +0,0 @@
|
||||
## Request throttling
|
||||
|
||||
Debounce and cancel high-frequency server calls
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Some user interactions trigger bursts of server requests that can overlap and return out of order. We’ll debounce frequent triggers and cancel in-flight requests (or ignore stale results) for file search and LSP refresh.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Reduce redundant calls from file search and LSP refresh
|
||||
- Prevent stale responses from overwriting newer UI state
|
||||
- Preserve responsive typing and scrolling during high activity
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing server-side behavior or adding new endpoints
|
||||
- Implementing global request queues for all SDK calls
|
||||
- Persisting search results across reloads
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- File search calls `sdk.client.find.files` via `files.searchFilesAndDirectories`.
|
||||
- LSP refresh is triggered frequently (exact call sites vary, but the refresh behavior is high-frequency).
|
||||
- Large UI modules involved include `packages/app/src/pages/layout.tsx` and `packages/app/src/components/prompt-input.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
- Add a small request coordinator utility:
|
||||
- debounced triggering (leading/trailing configurable)
|
||||
- cancellation via `AbortController` when supported
|
||||
- stale-result protection via monotonic request ids when abort is not supported
|
||||
- Integrate coordinator into:
|
||||
- `files.searchFilesAndDirectories` (wrap `sdk.client.find.files`)
|
||||
- LSP refresh call path (wrap refresh invocation and ensure only latest applies)
|
||||
|
||||
---
|
||||
|
||||
### Phased implementation steps
|
||||
|
||||
1. Add a debounced + cancellable helper
|
||||
|
||||
- Create `packages/app/src/utils/requests.ts` with:
|
||||
- `createDebouncedAsync(fn, delayMs)`
|
||||
- `createLatestOnlyAsync(fn)` that drops stale responses
|
||||
- Prefer explicit, readable primitives over a single complex abstraction.
|
||||
|
||||
Sketch:
|
||||
|
||||
```ts
|
||||
function createLatestOnlyAsync<TArgs extends unknown[], TResult>(
|
||||
fn: (args: { input: TArgs; signal?: AbortSignal }) => Promise<TResult>,
|
||||
) {
|
||||
let id = 0
|
||||
let controller: AbortController | undefined
|
||||
|
||||
return async (...input: TArgs) => {
|
||||
id += 1
|
||||
const current = id
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
|
||||
const result = await fn({ input, signal: controller.signal })
|
||||
if (current !== id) return
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Apply to file search
|
||||
|
||||
- Update `files.searchFilesAndDirectories` to:
|
||||
- debounce input changes (e.g. 150–300 ms)
|
||||
- abort prior request when a new query begins
|
||||
- ignore results if they are stale
|
||||
- Ensure “empty query” is handled locally without calling the server.
|
||||
|
||||
3. Apply to LSP refresh
|
||||
|
||||
- Identify the refresh trigger points used during typing and file switching.
|
||||
- Add:
|
||||
- debounce for rapid triggers (e.g. 250–500 ms)
|
||||
- cancellation for in-flight refresh if supported
|
||||
- last-write-wins behavior for applying diagnostics/results
|
||||
|
||||
4. Add feature flags and metrics
|
||||
|
||||
- Add flags:
|
||||
- `requests.debounce.fileSearch`
|
||||
- `requests.latestOnly.lspRefresh`
|
||||
- Add simple dev-only counters for “requests started / aborted / applied”.
|
||||
|
||||
---
|
||||
|
||||
### Data migration / backward compatibility
|
||||
|
||||
- No persisted data changes.
|
||||
- Behavior is compatible as long as UI state updates only when the “latest” request resolves.
|
||||
|
||||
---
|
||||
|
||||
### Risk + mitigations
|
||||
|
||||
- Risk: aggressive debounce makes UI feel laggy.
|
||||
- Mitigation: keep delays small and tune separately for search vs refresh.
|
||||
- Risk: aborting requests may surface as errors in logs.
|
||||
- Mitigation: treat `AbortError` as expected and do not log it as a failure.
|
||||
- Risk: SDK method may not accept `AbortSignal`.
|
||||
- Mitigation: use request-id stale protection even without true cancellation.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual scenarios:
|
||||
- type quickly in file search and confirm requests collapse and results stay correct
|
||||
- trigger LSP refresh repeatedly and confirm diagnostics do not flicker backward
|
||||
- Add a small unit test for latest-only behavior (stale results are ignored).
|
||||
|
||||
---
|
||||
|
||||
### Rollout plan
|
||||
|
||||
- Ship helpers behind flags default off.
|
||||
- Enable file search debounce first (high impact, easy to validate).
|
||||
- Enable LSP latest-only next, then add cancellation if SDK supports signals.
|
||||
- Keep a quick rollback by disabling the flags.
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- Does `sdk.client.find.files` accept an abort signal today, or do we need stale-result protection only?
|
||||
- Where is LSP refresh initiated, and does it have a single chokepoint we can wrap?
|
||||
- What debounce values feel best for common repos and slower machines?
|
||||
@@ -1,125 +0,0 @@
|
||||
## Spy acceleration
|
||||
|
||||
Replace O(N) DOM scans in session view
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
The session scroll-spy currently scans the DOM with `querySelectorAll` and walks message nodes, which becomes expensive as message count grows. We’ll replace the scan with an observer-based or indexed approach that scales smoothly.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove repeated full DOM scans during scroll in the session view
|
||||
- Keep “current message” tracking accurate during streaming and layout shifts
|
||||
- Provide a safe fallback path for older browsers and edge cases
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Visual redesign of the session page
|
||||
- Changing message rendering structure or IDs
|
||||
- Perfect accuracy during extreme layout thrash
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- `packages/app/src/pages/session.tsx` uses `querySelectorAll('[data-message-id]')` for scroll-spy.
|
||||
- The page is large and handles many responsibilities, increasing the chance of perf regressions.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
Implement a two-tier scroll-spy:
|
||||
|
||||
- Primary: `IntersectionObserver` to track which message elements are visible, updated incrementally.
|
||||
- Secondary: binary search over precomputed offsets when observer is unavailable or insufficient.
|
||||
- Use `ResizeObserver` (and a lightweight “dirty” flag) to refresh offsets only when layout changes.
|
||||
|
||||
---
|
||||
|
||||
### Phased implementation steps
|
||||
|
||||
1. Extract a dedicated scroll-spy module
|
||||
|
||||
- Create `packages/app/src/pages/session/scroll-spy.ts` (or similar) that exposes:
|
||||
- `register(el, id)` and `unregister(id)`
|
||||
- `getActiveId()` signal/store
|
||||
- Keep DOM operations centralized and easy to profile.
|
||||
|
||||
2. Add IntersectionObserver tracking
|
||||
|
||||
- Observe each `[data-message-id]` element once, on mount.
|
||||
- Maintain a small map of `id -> intersectionRatio` (or visible boolean).
|
||||
- Pick the active id by:
|
||||
- highest intersection ratio, then
|
||||
- nearest to top of viewport as a tiebreaker
|
||||
|
||||
3. Add binary search fallback
|
||||
|
||||
- Maintain an ordered list of `{ id, top }` positions.
|
||||
- On scroll (throttled via `requestAnimationFrame`), compute target Y and binary search to find nearest message.
|
||||
- Refresh the positions list on:
|
||||
- message list mutations (new messages)
|
||||
- container resize events (ResizeObserver)
|
||||
- explicit “layout changed” events after streaming completes
|
||||
|
||||
4. Remove `querySelectorAll` hot path
|
||||
|
||||
- Keep a one-time initial query only as a bridge during rollout, then remove it.
|
||||
- Ensure newly rendered messages are registered via refs rather than scanning the whole DOM.
|
||||
|
||||
5. Add a feature flag and fallback
|
||||
|
||||
- Add `session.scrollSpyOptimized` flag.
|
||||
- If observer setup fails, fall back to the existing scan behavior temporarily.
|
||||
|
||||
---
|
||||
|
||||
### Data migration / backward compatibility
|
||||
|
||||
- No persisted data changes.
|
||||
- IDs remain sourced from existing `data-message-id` attributes.
|
||||
|
||||
---
|
||||
|
||||
### Risk + mitigations
|
||||
|
||||
- Risk: observer ordering differs from previous “active message” logic.
|
||||
- Mitigation: keep selection rules simple, document them, and add a small tolerance for tie cases.
|
||||
- Risk: layout shifts cause incorrect offset indexing.
|
||||
- Mitigation: refresh offsets with ResizeObserver and after message streaming batches.
|
||||
- Risk: performance regressions from observing too many nodes.
|
||||
- Mitigation: prefer one observer instance and avoid per-node observers.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual scenarios:
|
||||
- very long sessions (hundreds of messages) and continuous scrolling
|
||||
- streaming responses that append content and change heights
|
||||
- resizing the window and toggling side panels
|
||||
- Add a dev-only profiler hook to log time spent in scroll-spy updates per second.
|
||||
|
||||
---
|
||||
|
||||
### Rollout plan
|
||||
|
||||
- Land extracted module first, still using the old scan internally.
|
||||
- Add observer implementation behind `session.scrollSpyOptimized` off by default.
|
||||
- Enable flag for internal testing, then default on after stability.
|
||||
- Keep fallback code for one release cycle, then remove scan path.
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- What is the exact definition of “active” used elsewhere (URL hash, sidebar highlight, breadcrumb)?
|
||||
- Are messages virtualized today, or are all DOM nodes mounted at once?
|
||||
- Which container is the scroll root (window vs an inner div), and does it change by layout mode?
|
||||
@@ -1,153 +0,0 @@
|
||||
## Component modularity
|
||||
|
||||
Split mega-components and dedupe scoped caches
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Several large UI files combine rendering, state, persistence, and caching patterns, including repeated “scoped session cache” infrastructure. We’ll extract reusable primitives and break large components into smaller units without changing user-facing behavior.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Reduce complexity in:
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/pages/layout.tsx`
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- Deduplicate “scoped session cache” logic into a shared utility
|
||||
- Make performance fixes (eviction, throttling) easier to implement safely
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Large redesign of routing or page structure
|
||||
- Moving to a different state management approach
|
||||
- Rewriting all contexts in one pass
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Session page is large and mixes concerns (`packages/app/src/pages/session.tsx`).
|
||||
- Layout is also large and likely coordinates multiple global concerns (`packages/app/src/pages/layout.tsx`).
|
||||
- Prompt input is large and includes persistence and interaction logic (`packages/app/src/components/prompt-input.tsx`).
|
||||
- Similar “scoped cache” patterns appear in multiple places (session-bound maps, per-session stores, ad hoc memoization).
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
- Introduce a shared “scoped store” utility to standardize session-bound caches:
|
||||
- keyed by `sessionId`
|
||||
- automatic cleanup via TTL or explicit `dispose(sessionId)`
|
||||
- optional LRU cap for many sessions
|
||||
- Break mega-components into focused modules with clear boundaries:
|
||||
- “view” components (pure rendering)
|
||||
- “controller” hooks (state + effects)
|
||||
- “services” (SDK calls, persistence adapters)
|
||||
|
||||
---
|
||||
|
||||
### Phased implementation steps
|
||||
|
||||
1. Inventory and name the repeated pattern
|
||||
|
||||
- Identify the repeated “scoped session cache” usage sites in:
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/pages/layout.tsx`
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- Write down the common operations (get-or-create, clear-on-session-change, dispose).
|
||||
|
||||
2. Add a shared scoped-cache utility
|
||||
|
||||
- Create `packages/app/src/utils/scoped-cache.ts`:
|
||||
- `createScopedCache(createValue, opts)` returning `get(key)`, `peek(key)`, `delete(key)`, `clear()`
|
||||
- optional TTL + LRU caps to avoid leak-by-design
|
||||
- Keep the API tiny and explicit so call sites stay readable.
|
||||
|
||||
Sketch:
|
||||
|
||||
```ts
|
||||
type ScopedOpts = { maxEntries?: number; ttlMs?: number }
|
||||
|
||||
function createScopedCache<T>(createValue: (key: string) => T, opts: ScopedOpts) {
|
||||
// store + eviction + dispose hooks
|
||||
}
|
||||
```
|
||||
|
||||
3. Extract session page submodules
|
||||
|
||||
- Split `packages/app/src/pages/session.tsx` into:
|
||||
- `session/view.tsx` for rendering layout
|
||||
- `session/messages.tsx` for message list
|
||||
- `session/composer.tsx` for input wiring
|
||||
- `session/scroll-spy.ts` for active message tracking
|
||||
- Keep exports stable so routing code changes minimally.
|
||||
|
||||
4. Extract layout coordination logic
|
||||
|
||||
- Split `packages/app/src/pages/layout.tsx` into:
|
||||
- shell layout view
|
||||
- navigation/controller logic
|
||||
- global keyboard shortcuts (if present)
|
||||
- Ensure each extracted piece has a narrow prop surface and no hidden globals.
|
||||
|
||||
5. Extract prompt-input state machine
|
||||
|
||||
- Split `packages/app/src/components/prompt-input.tsx` into:
|
||||
- `usePromptComposer()` hook (draft, submission, attachments)
|
||||
- presentational input component
|
||||
- Route persistence through existing `packages/app/src/context/prompt.tsx`, but isolate wiring code.
|
||||
|
||||
6. Replace ad hoc scoped caches with the shared utility
|
||||
|
||||
- Swap one call site at a time and keep behavior identical.
|
||||
- Add a flag `scopedCache.shared` to fall back to the old implementation if needed.
|
||||
|
||||
---
|
||||
|
||||
### Data migration / backward compatibility
|
||||
|
||||
- No persisted schema changes are required by modularization alone.
|
||||
- If any cache keys change due to refactors, keep a compatibility reader for one release cycle.
|
||||
|
||||
---
|
||||
|
||||
### Risk + mitigations
|
||||
|
||||
- Risk: refactors cause subtle behavior changes (focus, keyboard shortcuts, scroll position).
|
||||
- Mitigation: extract without logic changes first, then improve behavior in later diffs.
|
||||
- Risk: new shared cache introduces lifecycle bugs.
|
||||
- Mitigation: require explicit cleanup hooks and add dev assertions for retained keys.
|
||||
- Risk: increased file count makes navigation harder temporarily.
|
||||
- Mitigation: use consistent naming and keep the folder structure shallow.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual regression checklist:
|
||||
- compose, attach images, submit, and reload draft
|
||||
- navigate between sessions and confirm caches don’t bleed across IDs
|
||||
- verify terminal, file search, and scroll-spy still behave normally
|
||||
- Add lightweight unit tests for `createScopedCache` eviction and disposal behavior.
|
||||
|
||||
---
|
||||
|
||||
### Rollout plan
|
||||
|
||||
- Phase 1: introduce `createScopedCache` unused, then adopt in one low-risk area.
|
||||
- Phase 2: extract session submodules with no behavior changes.
|
||||
- Phase 3: flip remaining scoped caches to shared utility behind `scopedCache.shared`.
|
||||
- Phase 4: remove old duplicated implementations after confidence.
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- Where exactly is “scoped session cache” duplicated today, and what are the differing lifecycle rules?
|
||||
- Which extracted modules must remain synchronous for Solid reactivity to behave correctly?
|
||||
- Are there implicit dependencies in the large files (module-level state) that need special handling?
|
||||
@@ -1,237 +0,0 @@
|
||||
# App i18n Audit (Remaining Work)
|
||||
|
||||
Scope: `packages/app/`
|
||||
|
||||
Date: 2026-01-20
|
||||
|
||||
This report documents the remaining user-facing strings in `packages/app/src` that are still hardcoded (not routed through `useLanguage().t(...)` / translation keys), plus i18n-adjacent issues like locale-sensitive formatting.
|
||||
|
||||
## Current State
|
||||
|
||||
- The app uses `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`.
|
||||
- Recent progress (already translated): `packages/app/src/pages/home.tsx`, `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/session.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/app/src/components/dialog-connect-provider.tsx`, `packages/app/src/components/session/session-header.tsx`, `packages/app/src/pages/error.tsx`, `packages/app/src/components/session/session-new-view.tsx`, `packages/app/src/components/session-context-usage.tsx`, `packages/app/src/components/session/session-context-tab.tsx`, `packages/app/src/components/session-lsp-indicator.tsx`, `packages/app/src/components/session/session-sortable-tab.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/components/dialog-select-model.tsx`, `packages/app/src/context/notification.tsx`, `packages/app/src/context/global-sync.tsx`, `packages/app/src/context/file.tsx`, `packages/app/src/context/local.tsx`, `packages/app/src/utils/prompt.ts`, `packages/app/src/context/terminal.tsx`, `packages/app/src/components/session/session-sortable-terminal-tab.tsx` (plus new keys added in both dictionaries).
|
||||
- Dictionary parity check: `en.ts` and `zh.ts` currently contain the same key set (373 keys each; no missing or extra keys).
|
||||
|
||||
## Methodology
|
||||
|
||||
- Scanned `packages/app/src` (excluding `packages/app/src/i18n/*` and tests).
|
||||
- Grepped for:
|
||||
- Hardcoded JSX text nodes (e.g. `>Some text<`)
|
||||
- Hardcoded prop strings (e.g. `title="..."`, `placeholder="..."`, `label="..."`, `description="..."`, `Tooltip value="..."`)
|
||||
- Toast/notification strings, default fallbacks, and error message templates.
|
||||
- Manually reviewed top hits to distinguish:
|
||||
- User-facing UI copy (needs translation)
|
||||
- Developer-only logs (`console.*`) (typically does not need translation)
|
||||
- Technical identifiers (e.g. `MCP`, `LSP`, URLs) (may remain untranslated by choice).
|
||||
|
||||
## Highest Priority: Pages
|
||||
|
||||
### 1) Error Page
|
||||
|
||||
File: `packages/app/src/pages/error.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized page UI copy via `error.page.*` keys (title, description, buttons, report text, version label).
|
||||
- Localized error chain framing and common init error templates via `error.chain.*` keys.
|
||||
- Kept raw server/provider error messages as-is when provided (only localizing labels and structure).
|
||||
|
||||
## Highest Priority: Components
|
||||
|
||||
### 2) Prompt Input
|
||||
|
||||
File: `packages/app/src/components/prompt-input.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized placeholder examples by replacing the hardcoded `PLACEHOLDERS` list with `prompt.example.*` keys.
|
||||
- Localized toast titles/descriptions via `prompt.toast.*` and reused `common.requestFailed` for fallback error text.
|
||||
- Localized popover empty states and drag/drop overlay copy (`prompt.popover.*`, `prompt.dropzone.label`).
|
||||
- Localized smaller labels (slash "custom" badge, attach button tooltip, Send/Stop tooltip labels).
|
||||
- Kept the `ESC` keycap itself untranslated (key label).
|
||||
|
||||
### 3) Provider Connection / Auth Flow
|
||||
|
||||
File: `packages/app/src/components/dialog-connect-provider.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized all user-visible copy via `provider.connect.*` keys (titles, statuses, validations, instructions, OpenCode Zen onboarding).
|
||||
- Added `common.submit` and used it for both API + OAuth submit buttons.
|
||||
- Localized the success toast via `provider.connect.toast.connected.*`.
|
||||
|
||||
### 4) Session Header (Share/Publish UI)
|
||||
|
||||
File: `packages/app/src/components/session/session-header.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized search placeholder via `session.header.search.placeholder`.
|
||||
- Localized share/publish UI via `session.share.*` keys (popover title/description, button states, copy tooltip).
|
||||
- Reused existing command keys for toggle/share tooltips (`command.review.toggle`, `command.terminal.toggle`, `command.session.share`).
|
||||
|
||||
## Medium Priority: Components
|
||||
|
||||
### 5) New Session View
|
||||
|
||||
File: `packages/app/src/components/session/session-new-view.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Reused existing `command.session.new` for the heading.
|
||||
- Localized worktree labels via `session.new.worktree.*` (main branch, main branch w/ branch name, create worktree).
|
||||
- Localized "Last modified" via `session.new.lastModified` and used `language.locale()` for Luxon relative time.
|
||||
|
||||
### 6) Context Usage Tooltip
|
||||
|
||||
File: `packages/app/src/components/session-context-usage.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized tooltip labels + CTA via `context.usage.*` keys.
|
||||
- Switched currency and number formatting to the active locale (`language.locale()`).
|
||||
|
||||
### 7) Session Context Tab (Formatting)
|
||||
|
||||
File: `packages/app/src/components/session/session-context-tab.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Switched currency formatting to the active locale (`language.locale()`).
|
||||
- Also used `language.locale()` for number/date formatting.
|
||||
- Note: "—" placeholders remain hardcoded; optional to localize.
|
||||
|
||||
### 8) LSP Indicator
|
||||
|
||||
File: `packages/app/src/components/session-lsp-indicator.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized tooltip/label framing via `lsp.*` keys (kept the acronym itself).
|
||||
|
||||
### 9) Session Tab Close Tooltip
|
||||
|
||||
File: `packages/app/src/components/session/session-sortable-tab.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Reused `common.closeTab` for the close tooltip.
|
||||
|
||||
### 10) Titlebar Tooltip
|
||||
|
||||
File: `packages/app/src/components/titlebar.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Reused `command.sidebar.toggle` for the tooltip title.
|
||||
|
||||
### 11) Model Selection "Recent" Group
|
||||
|
||||
File: `packages/app/src/components/dialog-select-model.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Removed the unused hardcoded "Recent" group comparisons to avoid locale-coupled sorting.
|
||||
|
||||
### 12) Select Server Dialog Placeholder (Optional)
|
||||
|
||||
File: `packages/app/src/components/dialog-select-server.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Moved the placeholder example URL behind `dialog.server.add.placeholder` (value unchanged).
|
||||
|
||||
## Medium Priority: Context Modules
|
||||
|
||||
### 13) OS/Desktop Notifications
|
||||
|
||||
File: `packages/app/src/context/notification.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized OS notification titles/fallback copy via `notification.session.*` keys.
|
||||
|
||||
### 14) Global Sync (Bootstrap Errors + Toast)
|
||||
|
||||
File: `packages/app/src/context/global-sync.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized the sessions list failure toast via `toast.session.listFailed.title`.
|
||||
- Localized the bootstrap connection error via `error.globalSync.connectFailed`.
|
||||
|
||||
### 15) File Load Failure Toast (Duplicate)
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- `packages/app/src/context/local.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Introduced `toast.file.loadFailed.title` and reused it in both contexts.
|
||||
|
||||
### 16) Terminal Naming (Tricky)
|
||||
|
||||
File: `packages/app/src/context/terminal.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Terminal display labels are now rendered from a stable numeric `titleNumber` and localized via `terminal.title.*`.
|
||||
- Added a one-time migration to backfill missing `titleNumber` by parsing the stored title string.
|
||||
|
||||
## Low Priority: Utils / Dev-Only Copy
|
||||
|
||||
### 17) Default Attachment Filename
|
||||
|
||||
File: `packages/app/src/utils/prompt.ts`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Added `common.attachment` and plumbed it into `extractPromptFromParts(...)` as `opts.attachmentName`.
|
||||
|
||||
### 18) Dev-only Root Mount Error
|
||||
|
||||
File: `packages/app/src/entry.tsx`
|
||||
|
||||
Completed (2026-01-20):
|
||||
|
||||
- Localized the DEV-only root mount error via `error.dev.rootNotFound`.
|
||||
- Selected locale using `navigator.languages` to match the app’s default detection.
|
||||
|
||||
## Prioritized Implementation Plan
|
||||
|
||||
No remaining work in `packages/app/` as of 2026-01-20.
|
||||
|
||||
## Suggested Key Naming Conventions
|
||||
|
||||
To keep the dictionaries navigable, prefer grouping by surface:
|
||||
|
||||
- `error.page.*`, `error.chain.*`
|
||||
- `prompt.*` (including examples, tooltips, empty states, toasts)
|
||||
- `provider.connect.*` (auth flow UI + validation + success)
|
||||
- `session.share.*` (publish/unpublish/copy link)
|
||||
- `context.usage.*` (Tokens/Usage/Cost + call to action)
|
||||
- `lsp.*` (and potentially `mcp.*` if expanded)
|
||||
- `notification.session.*`
|
||||
- `toast.file.*`, `toast.session.*`
|
||||
|
||||
Also reuse existing command keys for tooltip titles whenever possible (e.g. `command.sidebar.toggle`, `command.review.toggle`, `command.terminal.toggle`).
|
||||
|
||||
## Appendix: Remaining Files At-a-Glance
|
||||
|
||||
Pages:
|
||||
|
||||
- (none)
|
||||
|
||||
Components:
|
||||
|
||||
- (none)
|
||||
|
||||
Context:
|
||||
|
||||
- (none)
|
||||
|
||||
Utils:
|
||||
|
||||
- (none)
|
||||
@@ -1,156 +0,0 @@
|
||||
# UI i18n Audit (Remaining Work)
|
||||
|
||||
Scope: `packages/ui/` (and consumers: `packages/app/`, `packages/enterprise/`)
|
||||
|
||||
Date: 2026-01-20
|
||||
|
||||
This report documents the remaining user-facing strings in `packages/ui/src` that are still hardcoded (not routed through a translation function), and proposes an i18n architecture that works long-term across multiple packages.
|
||||
|
||||
## Current State
|
||||
|
||||
- `packages/app/` already has i18n via `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`.
|
||||
- `packages/ui/` is a shared component library used by:
|
||||
- `packages/app/src/pages/session.tsx` (Session UI)
|
||||
- `packages/enterprise/src/routes/share/[shareID].tsx` (shared session rendering)
|
||||
- `packages/ui/` currently has **hardcoded English UI copy** in several components (notably `session-turn.tsx`, `session-review.tsx`, `message-part.tsx`).
|
||||
- `packages/enterprise/` does not currently have an i18n system, so any i18n approach must be usable without depending on `packages/app/`.
|
||||
|
||||
## Decision: How We Should Add i18n To `@opencode-ai/ui`
|
||||
|
||||
Introduce a small, app-agnostic i18n interface in `packages/ui/` and keep UI-owned strings in UI-owned dictionaries.
|
||||
|
||||
Why this is the best long-term shape:
|
||||
|
||||
- Keeps dependency direction clean: `packages/enterprise/` (and any future consumer) can translate UI without importing `packages/app/` dictionaries.
|
||||
- Avoids prop-drilling strings through shared components.
|
||||
- Allows each package to own its strings while still rendering a single, coherent locale in the product.
|
||||
|
||||
### Proposed Architecture
|
||||
|
||||
1. **UI provides an i18n context (no persistence)**
|
||||
|
||||
- Add `packages/ui/src/context/i18n.tsx`:
|
||||
- Exports `I18nProvider` and `useI18n()`.
|
||||
- Context value includes:
|
||||
- `t(key, params?)` translation function (template interpolation supported by the consumer).
|
||||
- `locale()` accessor for locale-sensitive formatting (Luxon/Intl).
|
||||
- Context should have a safe default (English) so UI components can render even if a consumer forgets the provider.
|
||||
|
||||
2. **UI owns UI strings (dictionaries live in UI)**
|
||||
|
||||
- Add `packages/ui/src/i18n/en.ts` and `packages/ui/src/i18n/zh.ts`.
|
||||
- Export them from `@opencode-ai/ui` via `packages/ui/package.json` exports (e.g. `"./i18n/*": "./src/i18n/*.ts"`).
|
||||
- Use a clear namespace prefix for all UI keys to avoid collisions:
|
||||
- Recommended: `ui.*` (e.g. `ui.sessionReview.title`).
|
||||
|
||||
3. **Consumers merge dictionaries and provide `t`/`locale` once**
|
||||
|
||||
- `packages/app/`:
|
||||
- Keep `packages/app/src/context/language.tsx` as the source of truth for locale selection/persistence.
|
||||
- Extend it to merge UI dictionaries into its translation table.
|
||||
- Add a tiny bridge provider in `packages/app/src/app.tsx` to feed `useLanguage()` into `@opencode-ai/ui`'s `I18nProvider`.
|
||||
|
||||
- `packages/enterprise/`:
|
||||
- Add a lightweight locale detector (similar to `packages/app/src/context/language.tsx`), likely based on `Accept-Language` on the server and/or `navigator.languages` on the client.
|
||||
- Merge `@opencode-ai/ui` dictionaries and (optionally) enterprise-local dictionaries.
|
||||
- Wrap the share route in `I18nProvider`.
|
||||
|
||||
### Key Naming Conventions (UI)
|
||||
|
||||
- Prefer component + semantic grouping:
|
||||
- `ui.sessionReview.title`
|
||||
- `ui.sessionReview.diffStyle.unified`
|
||||
- `ui.sessionReview.diffStyle.split`
|
||||
- `ui.sessionReview.expandAll`
|
||||
- `ui.sessionReview.collapseAll`
|
||||
|
||||
- For `SessionTurn`:
|
||||
- `ui.sessionTurn.steps.show`
|
||||
- `ui.sessionTurn.steps.hide`
|
||||
- `ui.sessionTurn.summary.response`
|
||||
- `ui.sessionTurn.diff.more` (use templating: `Show more changes ({{count}})`)
|
||||
- `ui.sessionTurn.retry.retrying` / `ui.sessionTurn.retry.inSeconds` / etc (avoid string concatenation that is English-order dependent)
|
||||
- Status text:
|
||||
- `ui.sessionTurn.status.delegating`
|
||||
- `ui.sessionTurn.status.planning`
|
||||
- `ui.sessionTurn.status.gatheringContext`
|
||||
- `ui.sessionTurn.status.searchingCode`
|
||||
- `ui.sessionTurn.status.searchingWeb`
|
||||
- `ui.sessionTurn.status.makingEdits`
|
||||
- `ui.sessionTurn.status.runningCommands`
|
||||
- `ui.sessionTurn.status.thinking`
|
||||
- `ui.sessionTurn.status.thinkingWithTopic` (template: `Thinking - {{topic}}`)
|
||||
- `ui.sessionTurn.status.gatheringThoughts`
|
||||
- `ui.sessionTurn.status.consideringNextSteps` (fallback)
|
||||
|
||||
## Locale-Sensitive Formatting (UI)
|
||||
|
||||
`SessionTurn` currently formats durations via Luxon `Interval.toDuration(...).toHuman(...)` without an explicit locale.
|
||||
|
||||
When i18n is added:
|
||||
|
||||
- Use `useI18n().locale()` and pass locale explicitly:
|
||||
- Luxon: `duration.toHuman({ locale: locale(), ... })` (or set `.setLocale(locale())` where applicable).
|
||||
- Intl numbers/currency (if added later): `new Intl.NumberFormat(locale(), ...)`.
|
||||
|
||||
## Initial Hardcoded Strings (Audit Findings)
|
||||
|
||||
These are the highest-impact UI surfaces to translate first.
|
||||
|
||||
### 1) `packages/ui/src/components/session-review.tsx`
|
||||
|
||||
- `Session changes`
|
||||
- `Unified` / `Split`
|
||||
- `Collapse all` / `Expand all`
|
||||
|
||||
### 2) `packages/ui/src/components/session-turn.tsx`
|
||||
|
||||
- Tool/task status strings (e.g. `Delegating work`, `Searching the codebase`)
|
||||
- Steps toggle labels: `Show steps` / `Hide steps`
|
||||
- Summary section title: `Response`
|
||||
- Pagination CTA: `Show more changes ({{count}})`
|
||||
|
||||
### 3) `packages/ui/src/components/message-part.tsx`
|
||||
|
||||
Examples (non-exhaustive):
|
||||
|
||||
- `Error`
|
||||
- `Edit`
|
||||
- `Write`
|
||||
- `Type your own answer`
|
||||
- `Review your answers`
|
||||
|
||||
### 4) Additional Hardcoded Strings (Full Audit)
|
||||
|
||||
Found during a full `packages/ui/src/components` + `packages/ui/src/context` sweep:
|
||||
|
||||
- `packages/ui/src/components/list.tsx`
|
||||
- `Loading`
|
||||
- `No results`
|
||||
- `No results for "{{filter}}"`
|
||||
- `packages/ui/src/components/message-nav.tsx`
|
||||
- `New message`
|
||||
- `packages/ui/src/components/text-field.tsx`
|
||||
- `Copied`
|
||||
- `Copy to clipboard`
|
||||
- `packages/ui/src/components/image-preview.tsx`
|
||||
- `Image preview` (alt text)
|
||||
|
||||
## Prioritized Implementation Plan
|
||||
|
||||
1. Completed (2026-01-20): Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it.
|
||||
2. Completed (2026-01-20): Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them.
|
||||
3. Completed (2026-01-20): Wire `I18nProvider` into:
|
||||
- `packages/app/src/app.tsx`
|
||||
- `packages/enterprise/src/app.tsx`
|
||||
4. Completed (2026-01-20): Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`.
|
||||
5. Completed (2026-01-20): Convert `packages/ui/src/components/message-part.tsx`.
|
||||
6. Completed (2026-01-20): Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy.
|
||||
|
||||
## Notes / Risks
|
||||
|
||||
- **SSR:** Enterprise share pages render on the server. Ensure the i18n provider works in SSR and does not assume `window`/`navigator`.
|
||||
- **Key collisions:** Use a consistent `ui.*` prefix to avoid clashing with app keys.
|
||||
- **Fallback behavior:** Decide whether missing keys should:
|
||||
- fall back to English, or
|
||||
- render the key (useful for catching missing translations).
|
||||
@@ -1,255 +0,0 @@
|
||||
## App E2E Smoke Suite (CI)
|
||||
|
||||
Implement a small set of high-signal, low-flake Playwright tests to run in CI.
|
||||
|
||||
These tests are intended to catch regressions in the “core shell” of the app (navigation, dialogs, prompt UX, file viewer, terminal), without relying on model output.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Add 6 smoke tests to `packages/app/e2e/`:
|
||||
|
||||
- Settings dialog: open, switch tabs, close
|
||||
- Prompt slash command: `/open` opens the file picker dialog
|
||||
- Prompt @mention: `@<file>` inserts a file pill token
|
||||
- Model picker: open model selection and choose a model
|
||||
- File viewer: open a known file and assert contents render
|
||||
- Terminal: open terminal, verify Ghostty mounts, create a second terminal
|
||||
|
||||
---
|
||||
|
||||
### Progress
|
||||
|
||||
- [x] 1. Settings dialog open / switch / close (`packages/app/e2e/settings.spec.ts`)
|
||||
- [x] 2. Prompt slash command path: `/open` opens file picker (`packages/app/e2e/prompt-slash-open.spec.ts`)
|
||||
- [x] 3. Prompt @mention inserts a file pill token (`packages/app/e2e/prompt-mention.spec.ts`)
|
||||
- [x] 4. Model selection UI works end-to-end (`packages/app/e2e/model-picker.spec.ts`)
|
||||
- [x] 5. File viewer renders real file content (`packages/app/e2e/file-viewer.spec.ts`)
|
||||
- [x] 8. Terminal init + create new terminal (`packages/app/e2e/terminal-init.spec.ts`)
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Tests run reliably in CI using the existing local runner (`packages/app/script/e2e-local.ts`).
|
||||
- Cover “wiring” regressions across UI + backend APIs:
|
||||
- dialogs + command routing
|
||||
- prompt contenteditable parsing
|
||||
- file search + file read + code viewer render
|
||||
- terminal open + pty creation + Ghostty mount
|
||||
- Avoid assertions that depend on LLM output.
|
||||
- Keep runtime low (these should be “smoke”, not full workflows).
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Verifying complex model behavior, streaming correctness, or tool call semantics.
|
||||
- Testing provider auth flows (CI has no secrets).
|
||||
- Testing share, MCP, or LSP download flows (disabled in the e2e runner).
|
||||
|
||||
---
|
||||
|
||||
### Current State
|
||||
|
||||
Existing tests in `packages/app/e2e/` already cover:
|
||||
|
||||
- Home renders + server picker opens
|
||||
- Directory route redirects to `/session`
|
||||
- Sidebar collapse/expand
|
||||
- Command palette opens/closes
|
||||
- Basic session open + prompt input + (optional) prompt/reply flow
|
||||
- File open via palette (but shallow assertion: tab exists)
|
||||
- Terminal panel toggles (but doesn’t assert Ghostty mounted)
|
||||
- Context panel open
|
||||
|
||||
We want to add a focused smoke layer that increases coverage of the most regression-prone UI paths.
|
||||
|
||||
---
|
||||
|
||||
### Proposed Tests
|
||||
|
||||
All tests should use the shared fixtures in:
|
||||
|
||||
- `packages/app/e2e/fixtures.ts` (for `sdk`, `directory`, `gotoSession`)
|
||||
- `packages/app/e2e/utils.ts` (for `modKey`, `promptSelector`, `terminalToggleKey`)
|
||||
|
||||
Prefer creating new spec files rather than overloading existing ones, so it’s easy to run these tests as a group via grep.
|
||||
|
||||
Suggested file layout:
|
||||
|
||||
- `packages/app/e2e/settings.spec.ts`
|
||||
- `packages/app/e2e/prompt-slash-open.spec.ts`
|
||||
- `packages/app/e2e/prompt-mention.spec.ts`
|
||||
- `packages/app/e2e/model-picker.spec.ts`
|
||||
- `packages/app/e2e/file-viewer.spec.ts`
|
||||
- `packages/app/e2e/terminal-init.spec.ts`
|
||||
|
||||
Name each test with a “smoke” prefix so CI can run only this suite if needed.
|
||||
|
||||
#### 1) Settings dialog open / switch / close
|
||||
|
||||
Purpose: catch regressions in dialog infra, settings rendering, tabs.
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Open settings via keybind (preferred for stability): `await page.keyboard.press(`${modKey}+Comma`)`.
|
||||
3. Assert dialog visible (`page.getByRole('dialog')`).
|
||||
4. Click the "Shortcuts" tab (role `tab`, name "Shortcuts").
|
||||
5. Assert shortcuts view renders (e.g. the search field placeholder or reset button exists).
|
||||
6. Close with `Escape` and assert dialog removed.
|
||||
|
||||
Notes:
|
||||
|
||||
- If `Meta+Comma` / `Control+Comma` key name is flaky, fall back to clicking the sidebar settings icon.
|
||||
- Favor role-based selectors over brittle class selectors.
|
||||
- If `Escape` doesn’t dismiss reliably (tooltips can intercept), fall back to clicking the dialog overlay.
|
||||
|
||||
Implementation: `packages/app/e2e/settings.spec.ts`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Settings dialog opens reliably.
|
||||
- Switching to Shortcuts tab works.
|
||||
- Escape closes the dialog.
|
||||
|
||||
#### 2) Prompt slash command path: `/open` opens file picker
|
||||
|
||||
Purpose: validate contenteditable parsing + slash popover + builtin command dispatch (distinct from `mod+p`).
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Click prompt (`promptSelector`).
|
||||
3. Type `/open`.
|
||||
4. Press `Enter` (while slash popover is active).
|
||||
5. Assert a dialog appears and contains a textbox (the file picker search input).
|
||||
6. Close dialog with `Escape`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `/open` triggers `file.open` and opens `DialogSelectFile`.
|
||||
|
||||
#### 3) Prompt @mention inserts a file pill token
|
||||
|
||||
Purpose: validate the most fragile prompt behavior: structured tokens inside contenteditable.
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Focus the prompt.
|
||||
3. Type `@packages/app/package.json`.
|
||||
4. Press `Tab` to accept the active @mention suggestion.
|
||||
5. Assert a pill element is inserted:
|
||||
- `page.locator('[data-component="prompt-input"] [data-type="file"][data-path="packages/app/package.json"]')` exists.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- A file pill is inserted and has the expected `data-*` attributes.
|
||||
- Prompt editor remains interactable (e.g. typing a trailing space works).
|
||||
|
||||
#### 4) Model selection UI works end-to-end
|
||||
|
||||
Purpose: validate model list rendering, selection wiring, and prompt footer updating.
|
||||
|
||||
Implementation approach:
|
||||
|
||||
- Use `/model` to open the model selection dialog (builtin command).
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Focus prompt, type `/model`, press `Enter`.
|
||||
3. In the model dialog, pick a visible model that is not the current selection (if available).
|
||||
4. Use the search field to filter to that model (use its id from the list item's `data-key` to avoid time-based model visibility drift).
|
||||
5. Select the filtered model.
|
||||
6. Assert dialog closed.
|
||||
7. Assert the prompt footer now shows the chosen model name.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- A model can be selected without requiring provider auth.
|
||||
- The prompt footer reflects the new selection.
|
||||
|
||||
#### 5) File viewer renders real file content
|
||||
|
||||
Purpose: ensure file search + open + file.read + code viewer render all work.
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Open file picker (either `mod+p` or `/open`).
|
||||
3. Search for `packages/app/package.json`.
|
||||
4. Click the matching file result.
|
||||
5. Ensure the new file tab is active (click the `package.json` tab if needed so the viewer mounts).
|
||||
6. Assert the code viewer contains a known substring:
|
||||
- `"name": "@opencode-ai/app"`.
|
||||
7. Optionally assert the file tab is active and visible.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Code view shows expected content (not just “tab exists”).
|
||||
|
||||
#### 8) Terminal init + create new terminal
|
||||
|
||||
Purpose: ensure terminal isn’t only “visible”, but actually mounted and functional.
|
||||
|
||||
Steps:
|
||||
|
||||
1. `await gotoSession()`.
|
||||
2. Open terminal with `terminalToggleKey` (currently `Control+Backquote`).
|
||||
3. Assert terminal container exists and is visible: `[data-component="terminal"]`.
|
||||
4. Assert Ghostty textarea exists: `[data-component="terminal"] textarea`.
|
||||
5. Create a new terminal via keybind (`terminal.new` is `ctrl+alt+t`).
|
||||
6. Assert terminal tab count increases to 2.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Ghostty mounts (textarea present).
|
||||
- Creating a new terminal results in a second tab.
|
||||
|
||||
---
|
||||
|
||||
### CI Stability + Flake Avoidance
|
||||
|
||||
These tests run with `fullyParallel: true` in `packages/app/playwright.config.ts`. Keep them isolated and deterministic.
|
||||
|
||||
- Avoid ordering-based assertions: never assume a “first” session/project/file is stable unless you filtered by unique text.
|
||||
- Prefer deterministic targets:
|
||||
- use `packages/app/package.json` rather than bare `package.json` (multiple hits possible)
|
||||
- for models, avoid hardcoding a single model id; pick from the visible list and filter by its `data-key` instead
|
||||
- Prefer robust selectors:
|
||||
- role selectors: `getByRole('dialog')`, `getByRole('textbox')`, `getByRole('tab')`
|
||||
- stable data attributes already present: `promptSelector`, `[data-component="terminal"]`
|
||||
- Keep tests local and fast:
|
||||
- do not submit prompts that require real model replies
|
||||
- avoid `page.waitForTimeout`; use `expect(...).toBeVisible()` and `expect.poll` when needed
|
||||
- Watch for silent UI failures:
|
||||
- capture `page.on('pageerror')` and fail test if any are emitted
|
||||
- optionally capture console errors (`page.on('console', ...)`) and fail on `type==='error'`
|
||||
- Cleanup:
|
||||
- these tests should not need to create sessions
|
||||
- if a test ever creates sessions or PTYs directly, clean up with SDK calls in `finally`
|
||||
|
||||
---
|
||||
|
||||
### Validation Plan
|
||||
|
||||
Run locally:
|
||||
|
||||
- `cd packages/app`
|
||||
- `bun run test:e2e:local -- --grep smoke`
|
||||
|
||||
Verify:
|
||||
|
||||
- all new tests pass consistently across multiple runs
|
||||
- overall e2e suite time does not increase significantly
|
||||
|
||||
---
|
||||
|
||||
### Open Questions
|
||||
|
||||
- Should we add a small helper in `packages/app/e2e/utils.ts` for “type into prompt contenteditable” to reduce duplication?
|
||||
- Do we want to gate these smoke tests with a dedicated `@smoke` naming convention (or `test.describe('smoke', ...)`) so CI can target them explicitly?
|
||||
@@ -1,113 +0,0 @@
|
||||
## Session page decomposition
|
||||
|
||||
Split `pages/session.tsx` into focused modules without behavior changes.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/pages/session.tsx` is still a large (~3,655 LOC) route coordinator. Recent refactoring already extracted `packages/app/src/pages/session/helpers.ts` and `packages/app/src/pages/session/scroll-spy.ts`, but review-panel wiring, message timeline orchestration, file-tab rendering, and terminal coordination remain tightly coupled. This spec continues the decomposition from that updated baseline.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Reduce complexity in `packages/app/src/pages/session.tsx`.
|
||||
- Isolate major concerns into dedicated modules under `packages/app/src/pages/session/`.
|
||||
- Keep behavior and route/API contracts unchanged.
|
||||
- Preserve current keyboard, scroll, hash, and review interactions.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No redesign of session UX.
|
||||
- No changes to SDK contracts.
|
||||
- No refactor of `context/global-sync.tsx`, `context/file.tsx`, or `components/prompt-input.tsx` in this workstream.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- New files under `packages/app/src/pages/session/**`
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx` (owned by spec 10)
|
||||
- `packages/app/src/components/prompt-input.tsx` (owned by spec 11)
|
||||
- `packages/app/src/context/global-sync.tsx` (owned by spec 12)
|
||||
- `packages/app/src/context/file.tsx` (owned by spec 13)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- File size: ~3,655 LOC.
|
||||
- Existing extracted modules:
|
||||
- `packages/app/src/pages/session/helpers.ts` (terminal focus and shared handlers)
|
||||
- `packages/app/src/pages/session/scroll-spy.ts` (message visibility + active-section tracking)
|
||||
- High effect density (`createEffect`) and local-state density (`createStore` + `createSignal`) remain in `session.tsx`.
|
||||
- Remaining interleaved responsibilities:
|
||||
- review panel state + scrolling integration
|
||||
- message timeline + hash navigation wiring
|
||||
- file tab renderers + per-tab scroll sync
|
||||
- terminal panel and tab coordination
|
||||
|
||||
---
|
||||
|
||||
### Proposed module split
|
||||
|
||||
Build on the existing `packages/app/src/pages/session/` directory and keep current extracted helpers in place. Add modules such as:
|
||||
|
||||
- `review-panel.tsx` - review tab rendering and focused diff logic.
|
||||
- `message-timeline.tsx` - session turn rendering and active message tracking UI wiring.
|
||||
- `file-tabs.tsx` - file tab content rendering, file scroll persistence, and line-comment overlays.
|
||||
- `terminal-panel.tsx` - terminal tabs and focus behavior.
|
||||
- `use-session-page-state.ts` - page-level derived state and imperative handlers.
|
||||
|
||||
`packages/app/src/pages/session.tsx` remains the route entry and orchestrator only.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Keep `helpers.ts` and `scroll-spy.ts` as baseline; extract any additional pure helpers first (no behavior changes).
|
||||
2. Extract review panel subtree and related handlers.
|
||||
3. Extract file-tab subtree and scroll synchronization logic.
|
||||
4. Extract terminal panel subtree.
|
||||
5. Move page-level state/effects into `use-session-page-state.ts`.
|
||||
6. Reduce `session.tsx` to composition and routing glue.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `packages/app/src/pages/session.tsx` is reduced substantially (target: under 1,400 LOC).
|
||||
- No user-facing behavior changes in session, review, file tabs, or terminal tabs.
|
||||
- Event listeners and observers are still correctly cleaned up.
|
||||
- New modules have clear prop boundaries and minimal hidden coupling.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/session/session.spec.ts`
|
||||
- `e2e/files/file-viewer.spec.ts`
|
||||
- `e2e/terminal/terminal.spec.ts`
|
||||
- Manual checks:
|
||||
- message hash navigation
|
||||
- review diff focus + open-file action
|
||||
- terminal tab create/reorder/focus behavior
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep module interfaces narrow and data-oriented.
|
||||
- Prefer extracting code unchanged before doing any cleanup refactors.
|
||||
- If a helper is useful to other specs, place it under `pages/session/` for now; cross-spec shared utilities can be unified later.
|
||||
@@ -1,105 +0,0 @@
|
||||
## Session hot paths
|
||||
|
||||
Reduce render work and duplication in `session.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/pages/session.tsx` mixes routing, commands, tab rendering, review panel wiring, terminal focus logic, and message scrolling. This spec targets hot-path performance + local code quality improvements that can ship together in one session-page-focused PR. It should follow the keyed command-registration pattern introduced in `packages/app/src/context/command.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Render heavy file-tab content only for the active tab
|
||||
- Deduplicate review-panel wiring used in desktop and mobile paths
|
||||
- Centralize terminal-focus DOM logic into one helper
|
||||
- Reduce churn in command registration setup
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Scroll-spy rewrite (covered by `specs/04-scroll-spy-optimization.md`)
|
||||
- Large routing/layout redesign
|
||||
- Behavior changes to prompt submission or session history
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- New files under `packages/app/src/pages/session/*` (if extracted)
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/context/*`
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add shared helpers for repeated session-page actions
|
||||
|
||||
- Extract `openReviewFile(path)` helper to replace repeated inline `onViewFile` bodies.
|
||||
- Extract `focusTerminalById(id)` helper and reuse in both:
|
||||
- terminal active change effect
|
||||
- terminal drag-end focus restoration
|
||||
|
||||
2. Deduplicate review panel construction
|
||||
|
||||
- Build a shared review props factory (or local render helper) so desktop/mobile paths do not duplicate comment wiring, `onViewFile`, and classes glue.
|
||||
- Keep per-surface differences limited to layout classes and diff style.
|
||||
|
||||
3. Gate heavy file-tab rendering by active tab
|
||||
|
||||
- Keep tab trigger list rendered for all opened tabs.
|
||||
- Render `Tabs.Content` body only for `activeTab()`, plus lightweight placeholders as needed.
|
||||
- Ensure per-tab scroll state restore still works when reactivating a tab.
|
||||
|
||||
4. Reduce command registry reallocation
|
||||
|
||||
- Register session commands with a stable key (`command.register("session", ...)`) so remounts replace prior session command entries.
|
||||
- Move large command-array construction into smaller memoized blocks:
|
||||
- stable command definitions
|
||||
- dynamic state fields (`disabled`, titles) as narrow computed closures
|
||||
- Keep command IDs, keybinds, and behavior identical.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- File tab bodies are not all mounted at once for large open-tab sets.
|
||||
- `onViewFile` review behavior is defined in one shared helper.
|
||||
- Terminal focus query/dispatch logic lives in one function and is reused.
|
||||
- Session command registration uses a stable key (`"session"`) and `command.register` no longer contains one monolithic inline array with repeated inline handlers for shared actions.
|
||||
- Session UX remains unchanged for:
|
||||
- opening files from review
|
||||
- drag-reordering terminal tabs
|
||||
- keyboard command execution
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open 12+ file tabs, switch quickly, verify active tab restore and no blank states.
|
||||
- Open review panel (desktop and mobile), use "view file" from diffs, verify same behavior as before.
|
||||
- Drag terminal tab, ensure terminal input focus is restored.
|
||||
- Run key commands: `mod+p`, `mod+w`, `mod+shift+r`, `ctrl+``.
|
||||
- Perf sanity:
|
||||
- Compare CPU usage while switching tabs with many opened files before/after.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: unmounted tab content loses transient editor state.
|
||||
- Mitigation: keep persisted scroll/selection restore path intact and verify reactivation behavior.
|
||||
- Risk: command refactor subtly changes command ordering.
|
||||
- Mitigation: keep IDs and registration order stable, diff against current command list in dev.
|
||||
@@ -1,99 +0,0 @@
|
||||
## File cache accounting
|
||||
|
||||
Make file-content eviction bookkeeping O(1)
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/file.tsx` currently recomputes total cached bytes by reducing the entire LRU map inside the eviction loop. This creates avoidable overhead on large file sets. We will switch to incremental byte accounting while keeping LRU behavior unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove repeated full-map reductions from eviction path
|
||||
- Maintain accurate total byte tracking incrementally
|
||||
- Preserve existing eviction semantics (entry count + byte cap)
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing cache limits
|
||||
- Changing file loading API behavior
|
||||
- Introducing cross-session shared caches
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- Optional tests in `packages/app/src/context/*file*.test.ts`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Introduce incremental byte counters
|
||||
|
||||
- Add module-level `contentBytesTotal`.
|
||||
- Add helper(s):
|
||||
- `setContentBytes(path, nextBytes)`
|
||||
- `removeContentBytes(path)`
|
||||
- `resetContentBytes()`
|
||||
|
||||
2. Refactor LRU touch/update path
|
||||
|
||||
- Keep `contentLru` as LRU order map.
|
||||
- Update byte total only when a path is inserted/updated/removed.
|
||||
- Ensure replacing existing byte value updates total correctly.
|
||||
|
||||
3. Refactor eviction loop
|
||||
|
||||
- Use `contentBytesTotal` in loop condition instead of `Array.from(...).reduce(...)`.
|
||||
- On eviction, remove from both `contentLru` and byte counter.
|
||||
|
||||
4. Keep scope reset correct
|
||||
|
||||
- On directory scope change, clear inflight maps + `contentLru` + byte counter.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `evictContent` performs no full-map reduction per iteration.
|
||||
- Total bytes remain accurate after:
|
||||
- loading file A
|
||||
- loading file B
|
||||
- force-reloading file A with a different size
|
||||
- evicting entries
|
||||
- scope reset
|
||||
- Existing caps (`MAX_FILE_CONTENT_ENTRIES`, `MAX_FILE_CONTENT_BYTES`) continue to enforce correctly.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open many files with mixed sizes and verify old files still evict as before.
|
||||
- Switch directory scope and verify cache clears safely.
|
||||
- Optional unit coverage:
|
||||
- size counter updates on overwrite + delete.
|
||||
- eviction condition uses count and bytes as expected.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: byte counter drifts from map contents.
|
||||
- Mitigation: route all updates through centralized helpers.
|
||||
- Risk: stale bytes retained on early returns.
|
||||
- Mitigation: assert cleanup paths in `finally`/scope reset still execute.
|
||||
@@ -1,109 +0,0 @@
|
||||
## Layout page decomposition
|
||||
|
||||
Split `pages/layout.tsx` into composable layout modules with stable behavior.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/pages/layout.tsx` is a 3,000+ line coordinator for sidebar navigation, project/workspace controls, deep-link handling, dialogs, drag/drop overlays, and global shell interactions. This spec decomposes it into focused modules to improve maintainability and reduce merge risk for future features.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Break up `packages/app/src/pages/layout.tsx` into smaller units.
|
||||
- Separate rendering concerns from orchestration/state concerns.
|
||||
- Keep existing URL/navigation semantics and sidebar behavior.
|
||||
- Preserve all current command and dialog entry points.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No major UX redesign of the sidebar or project/workspace UI.
|
||||
- No changes to server/global-sync contracts.
|
||||
- No refactor of `pages/session.tsx` in this workstream.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx`
|
||||
- New files under `packages/app/src/pages/layout/**`
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/pages/session.tsx` (spec 09)
|
||||
- `packages/app/src/components/prompt-input.tsx` (spec 11)
|
||||
- `packages/app/src/context/global-sync.tsx` (spec 12)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- File size: ~3,004 LOC.
|
||||
- Contains mixed concerns:
|
||||
- app-shell rendering
|
||||
- sidebar/project/workspace UI + drag/drop
|
||||
- deep-link handling and startup flows
|
||||
- workspace reset/delete actions and toasts
|
||||
|
||||
---
|
||||
|
||||
### Proposed module split
|
||||
|
||||
Create `packages/app/src/pages/layout/` modules such as:
|
||||
|
||||
- `use-layout-page-state.ts` - orchestration state and handlers.
|
||||
- `sidebar-panel.tsx` - sidebar shell and root interactions.
|
||||
- `project-item.tsx` - project-level row and actions.
|
||||
- `workspace-item.tsx` - workspace row, sessions list, and workspace actions.
|
||||
- `deep-links.ts` - deep-link parsing/draining/handler utilities.
|
||||
|
||||
Keep `packages/app/src/pages/layout.tsx` as route-level composition and provider wiring.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Extract pure helpers first (deep-link parse, shared label helpers, small utility functions).
|
||||
2. Extract workspace subtree and action handlers.
|
||||
3. Extract project subtree and menu actions.
|
||||
4. Extract sidebar shell and drag overlay components.
|
||||
5. Move orchestration logic into `use-layout-page-state.ts`.
|
||||
6. Reduce `layout.tsx` to composition-only entry.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `packages/app/src/pages/layout.tsx` is significantly smaller (target: under 1,200 LOC).
|
||||
- Behavior parity for:
|
||||
- project open/close/rename
|
||||
- workspace expand/collapse/reset/delete
|
||||
- deep-link handling
|
||||
- drag/drop ordering
|
||||
- No regressions in keyboard navigation and dialog actions.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/sidebar/sidebar.spec.ts`
|
||||
- `e2e/projects/workspaces.spec.ts`
|
||||
- `e2e/projects/project-edit.spec.ts`
|
||||
- `e2e/app/navigation.spec.ts`
|
||||
- Manual check: deep-link open-project flow still opens and navigates correctly.
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep action handlers close to their domain module.
|
||||
- Do not merge in behavior cleanups during extraction; preserve semantics first.
|
||||
- If shared components are needed, add them under `pages/layout/` for now to avoid cross-spec conflicts.
|
||||
@@ -1,92 +0,0 @@
|
||||
## Layout reactivity
|
||||
|
||||
Reduce per-call reactive overhead in `useLayout`
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/layout.tsx` creates reactive effects inside `view(sessionKey)` and `tabs(sessionKey)` each time these helpers are called. Multiple consumers for the same key can accumulate duplicate watchers. This spec simplifies the API internals so calls stay lightweight while preserving behavior.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove avoidable per-call `createEffect` allocations in `view()` and `tabs()`
|
||||
- Preserve scroll seeding, pruning, and touch semantics
|
||||
- Keep external `useLayout` API stable
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Persistence schema migration
|
||||
- Session tab behavior redesign
|
||||
- New layout features
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/layout.tsx`
|
||||
- `packages/app/src/context/layout-scroll.test.ts` (if updates needed)
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/session/*`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Consolidate key-touch logic
|
||||
|
||||
- Introduce shared internal helper, e.g. `ensureSessionKey(key)` that performs:
|
||||
- `touch(key)`
|
||||
- `scroll.seed(key)`
|
||||
|
||||
2. Remove per-call effects in `view()` / `tabs()`
|
||||
|
||||
- Replace internal `createEffect(on(key, ...))` usage with lazy key reads inside accessors/memos.
|
||||
- Ensure reads still invoke `ensureSessionKey` at safe points.
|
||||
|
||||
3. Keep return API stable
|
||||
|
||||
- Preserve current method names and behavior:
|
||||
- `view(...).scroll`, `setScroll`, `terminal`, `reviewPanel`, `review`
|
||||
- `tabs(...).active`, `all`, `open`, `close`, `move`, etc.
|
||||
|
||||
4. Verify pruning behavior
|
||||
|
||||
- Ensure session-key pruning still runs when key set grows and active key changes.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `view()` and `tabs()` no longer instantiate per-call key-change effects.
|
||||
- Existing callers do not require API changes.
|
||||
- Scroll restore and tab persistence still work across session navigation.
|
||||
- No regressions in handoff/pending-message behavior.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Navigate across multiple sessions; verify tabs + review open state + scroll positions restore.
|
||||
- Toggle terminal/review panels and confirm persisted state remains consistent.
|
||||
- Tests:
|
||||
- Update/add targeted tests for key seeding/pruning if behavior changed.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: subtle key-touch ordering changes affect prune timing.
|
||||
- Mitigation: keep `touch` and `seed` coupled through one helper and verify prune boundaries.
|
||||
- Risk: removing effects misses updates for dynamic accessor keys.
|
||||
- Mitigation: ensure every public accessor path reads current key and calls helper.
|
||||
@@ -1,121 +0,0 @@
|
||||
## Prompt input and optimistic-state consolidation
|
||||
|
||||
Decompose prompt-input and unify optimistic message mutations.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/components/prompt-input.tsx` has already been partially decomposed and is now ~1,391 LOC. Editor DOM helpers, attachments, history, and submit flow were extracted into `packages/app/src/components/prompt-input/*.ts`, but optimistic mutation ownership and some UI/controller responsibilities are still split across call sites. This spec continues from that refactored baseline.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Split `prompt-input.tsx` into modular UI + controller pieces.
|
||||
- Centralize optimistic message add/remove behavior behind sync-context APIs.
|
||||
- Remove unsafe cast path around optimistic parts (`as unknown as Part[]`).
|
||||
- Keep existing prompt UX and submission semantics unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No redesign of prompt input visuals.
|
||||
- No changes to session protocol or backend APIs.
|
||||
- No changes to unrelated page modules (`pages/session.tsx`, `pages/layout.tsx`).
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- New files under `packages/app/src/components/prompt-input/**`
|
||||
- `packages/app/src/context/sync.tsx` (optimistic API surface only)
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/pages/session.tsx` (spec 09)
|
||||
- `packages/app/src/pages/layout.tsx` (spec 10)
|
||||
- `packages/app/src/context/global-sync.tsx` (spec 12)
|
||||
- `packages/app/src/context/file.tsx` (spec 13)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- File size: ~1,391 LOC for `prompt-input.tsx`.
|
||||
- Existing extracted modules:
|
||||
- `prompt-input/editor-dom.ts`
|
||||
- `prompt-input/attachments.ts`
|
||||
- `prompt-input/history.ts`
|
||||
- `prompt-input/submit.ts`
|
||||
- Optimistic mutation and request-part casting still need consolidation (including remaining `as unknown as Part[]` in submit path).
|
||||
- Remaining concerns still tightly coupled in `prompt-input.tsx`:
|
||||
- slash/mention UI rendering and keyboard orchestration
|
||||
- context pill interactions and focus behavior
|
||||
- composition glue across history/attachments/submit
|
||||
|
||||
---
|
||||
|
||||
### Proposed structure
|
||||
|
||||
Build on the existing `packages/app/src/components/prompt-input/` modules by adding/further splitting modules such as:
|
||||
|
||||
- `use-prompt-composer.ts` - state machine for submit/abort/history.
|
||||
- `build-request-parts.ts` - typed request-part construction.
|
||||
- `slash-popover.tsx` - slash command list rendering.
|
||||
- `context-items.tsx` - context pills and interactions.
|
||||
|
||||
Keep existing lower-level modules (`attachments.ts`, `editor-dom.ts`, `history.ts`, `submit.ts`) and narrow their responsibilities where needed.
|
||||
|
||||
Add sync-level optimistic APIs (in `context/sync.tsx` or `context/sync-optimistic.ts`):
|
||||
|
||||
- `session.optimistic.add(...)`
|
||||
- `session.optimistic.remove(...)`
|
||||
|
||||
Prompt input should call these APIs instead of directly mutating message/part stores.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Extract typed request-part builder (likely from `prompt-input/submit.ts`) to remove ad hoc casting.
|
||||
2. Introduce sync optimistic APIs with current behavior.
|
||||
3. Replace remaining direct `produce(...)` optimistic mutations with optimistic APIs.
|
||||
4. Extract remaining UI subtrees (slash popover, context items, toolbar controls).
|
||||
5. Extract controller hook and keep route component as composition shell.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Optimistic update logic exists in one place only.
|
||||
- `prompt-input.tsx` is significantly smaller (target: under 1,200 LOC).
|
||||
- Prompt submit/abort/history behavior remains unchanged.
|
||||
- No `as unknown as Part[]` in optimistic request construction path.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/prompt/prompt.spec.ts`
|
||||
- `e2e/prompt/context.spec.ts`
|
||||
- `e2e/prompt/prompt-slash-open.spec.ts`
|
||||
- `e2e/prompt/prompt-mention.spec.ts`
|
||||
- Manual check:
|
||||
- submit with file/image/context attachments
|
||||
- abort in-flight turn
|
||||
- history up/down restore behavior
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Preserve sequence semantics around optimistic insert, worktree wait, send, and rollback.
|
||||
- Keep sync optimistic API data-oriented and reusable by future callers.
|
||||
- Do not mix this with broader sync/global-sync refactors in the same diff.
|
||||
@@ -1,105 +0,0 @@
|
||||
## Global sync domain split
|
||||
|
||||
Refactor `context/global-sync.tsx` into domain modules while preserving behavior.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/global-sync.tsx` is a large multi-domain module (1,000+ LOC) that currently owns queue scheduling, bootstrap, child store creation, persistence bridges, session trimming, and event reduction. This workstream splits it into clear domains without changing runtime behavior.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Decompose global sync internals into maintainable modules.
|
||||
- Keep `useGlobalSync()` public API unchanged.
|
||||
- Isolate pure logic (session trimming, ordering, grouping) from side effects.
|
||||
- Keep event handling deterministic and easier to test.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No protocol/API changes to server events.
|
||||
- No behavior changes in session ordering, trimming, or cache semantics.
|
||||
- No changes to page-level UI logic.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/context/global-sync.tsx`
|
||||
- New files under `packages/app/src/context/global-sync/**`
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/context/file.tsx` (spec 13)
|
||||
- `packages/app/src/components/prompt-input.tsx` (spec 11)
|
||||
- `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx` (specs 09/10)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Single large module with many responsibilities.
|
||||
- Event reducer is embedded in component lifecycle code.
|
||||
- Queue/scheduler, bootstrap, and child-store lifecycle are tightly interwoven.
|
||||
|
||||
---
|
||||
|
||||
### Proposed module split
|
||||
|
||||
Create `packages/app/src/context/global-sync/` modules like:
|
||||
|
||||
- `types.ts` - shared types.
|
||||
- `queue.ts` - refresh queue and drain scheduler.
|
||||
- `child-store.ts` - child store creation, persistence wiring, cache maps.
|
||||
- `session-trim.ts` - pure session sorting/trimming helpers.
|
||||
- `bootstrap.ts` - global and per-directory bootstrap flows.
|
||||
- `event-reducer.ts` - event handlers for SDK event stream.
|
||||
|
||||
Keep `global-sync.tsx` as provider/composition entry point.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Extract pure helpers (`cmp`, session trim/recent logic) first.
|
||||
2. Extract queue/drain scheduler.
|
||||
3. Extract child-store creation and persisted cache wiring.
|
||||
4. Extract bootstrap flows.
|
||||
5. Extract event reducer and wire into existing listener.
|
||||
6. Keep API surface stable and documented.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Public API of `useGlobalSync()` remains backward compatible.
|
||||
- `global-sync.tsx` is substantially reduced (target: under 500 LOC).
|
||||
- Event handling logic is isolated and easier to trace.
|
||||
- No behavior regressions in project/session/provider sync.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/app/session.spec.ts`
|
||||
- `e2e/sidebar/sidebar-session-links.spec.ts`
|
||||
- `e2e/projects/projects-switch.spec.ts`
|
||||
- Manual checks:
|
||||
- switching directories/projects still hydrates child stores correctly
|
||||
- session list/pagination behavior remains stable
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Favor function extraction with unchanged code first.
|
||||
- Keep event handler ordering explicit; avoid implicit fallthrough behaviors.
|
||||
- Add focused tests only for extracted pure helpers if practical, but avoid broad test-suite changes here.
|
||||
@@ -1,96 +0,0 @@
|
||||
## Context metrics shared
|
||||
|
||||
Unify duplicate session usage calculations
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`session-context-tab.tsx` and `session-context-usage.tsx` both compute overlapping session metrics (cost, last assistant token totals, provider/model context usage). This creates duplicate loops and raises drift risk. We will centralize shared calculations in one helper module and have both components consume it.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Compute shared session usage metrics in one place
|
||||
- Remove duplicate loops for cost and latest-token context usage
|
||||
- Keep UI output unchanged in both components
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Rewriting the detailed context breakdown estimator logic
|
||||
- Changing translations or labels
|
||||
- Moving metrics into backend API responses
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/session/session-context-tab.tsx`
|
||||
- `packages/app/src/components/session-context-usage.tsx`
|
||||
- New helper in `packages/app/src/components/session/*` or `packages/app/src/utils/*`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/context/sync.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add shared metrics helper
|
||||
|
||||
- Create helper for raw metrics from message list + provider map, e.g.:
|
||||
- `totalCost`
|
||||
- `lastAssistantWithTokens`
|
||||
- `tokenTotal`
|
||||
- `tokenUsagePercent`
|
||||
- provider/model labels
|
||||
- Return raw numeric values; keep locale formatting in consumers.
|
||||
|
||||
2. Add memoization guard
|
||||
|
||||
- Use reference-based memoization (e.g. by message-array identity) inside helper or component-level memo to avoid duplicate recalculation on unchanged arrays.
|
||||
|
||||
3. Migrate both components
|
||||
|
||||
- Replace duplicated loops in:
|
||||
- `session-context-tab.tsx`
|
||||
- `session-context-usage.tsx`
|
||||
- Keep existing UI structure and i18n keys unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Shared cost + token calculations are defined in one module.
|
||||
- Both components read from the shared helper.
|
||||
- Rendered values remain identical for:
|
||||
- total cost
|
||||
- token totals
|
||||
- usage percentage
|
||||
- provider/model fallback labels
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open session context tab and compare values with header/context indicator tooltip.
|
||||
- Verify values update correctly while new assistant messages stream in.
|
||||
- Regression:
|
||||
- locale change still formats numbers/currency correctly.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: helper changes semantic edge cases (no provider, no model, missing token fields).
|
||||
- Mitigation: preserve existing fallback behavior (`"—"`, null percent).
|
||||
- Risk: memoization over-caches stale values.
|
||||
- Mitigation: key cache by message-array reference and dependent IDs only.
|
||||
@@ -1,111 +0,0 @@
|
||||
## File context domain split
|
||||
|
||||
Refactor `context/file.tsx` into focused modules with unchanged API.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/file.tsx` still combines path normalization, file-content caching/eviction, file-tree loading, watcher event handling, and file-view persistence orchestration. Recent refactoring extracted generic scoped-cache primitives to `packages/app/src/utils/scoped-cache.ts`, but most file-domain behavior remains in one module. This spec separates those concerns while preserving the existing `useFile()` interface.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Keep `useFile()` API stable for all callers.
|
||||
- Extract independent domains into dedicated modules.
|
||||
- Improve readability and lower risk for future file-tree/perf changes.
|
||||
- Preserve current caching and watcher semantics.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No redesign of file tree UI.
|
||||
- No change to backend file APIs.
|
||||
- No simultaneous refactor of `components/file-tree.tsx` in this workstream.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- New files under `packages/app/src/context/file/**`
|
||||
- `packages/app/src/utils/scoped-cache.ts` (only when required for file-view cache extraction)
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/context/global-sync.tsx` (spec 12)
|
||||
- `packages/app/src/pages/session.tsx` (spec 09)
|
||||
- `packages/app/src/components/prompt-input.tsx` (spec 11)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- File size: ~751 LOC.
|
||||
- `packages/app/src/utils/scoped-cache.ts` now exists as a shared cache primitive used by file view persistence.
|
||||
- Multiple domains in one module:
|
||||
- path normalization/parsing
|
||||
- LRU content memory management
|
||||
- tree node/directory state management
|
||||
- event-driven watcher invalidation
|
||||
- per-session view cache bootstrapping
|
||||
|
||||
---
|
||||
|
||||
### Proposed module split
|
||||
|
||||
Create `packages/app/src/context/file/` modules such as:
|
||||
|
||||
- `path.ts` - normalize/strip helpers.
|
||||
- `content-cache.ts` - content LRU + byte caps.
|
||||
- `view-cache.ts` - per-session file view persistence cache (building on `createScopedCache`).
|
||||
- `tree-store.ts` - directory/node store and list/expand/collapse actions.
|
||||
- `watcher.ts` - watcher event handling and invalidation routines.
|
||||
|
||||
`file.tsx` remains the provider entry that composes these modules.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Extract path helper functions with no behavior changes.
|
||||
2. Extract content cache and eviction logic.
|
||||
3. Extract file-specific view-cache loading/pruning logic on top of `createScopedCache`.
|
||||
4. Extract tree-store list/refresh/toggle actions.
|
||||
5. Extract watcher update handler and wire cleanup.
|
||||
6. Keep `useFile()` return shape unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `useFile()` API remains backward compatible.
|
||||
- `context/file.tsx` is reduced significantly (target: under 350 LOC).
|
||||
- Tree loading/refresh and content eviction behavior remain unchanged.
|
||||
- Watcher-driven reload behavior still works for changed/added/deleted files.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/files/file-tree.spec.ts`
|
||||
- `e2e/files/file-viewer.spec.ts`
|
||||
- `e2e/files/file-open.spec.ts`
|
||||
- Manual checks:
|
||||
- directory expand/collapse and refresh
|
||||
- large file navigation and cache reuse
|
||||
- watcher-driven updates in active file tabs
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep tree/data stores colocated with their mutation helpers.
|
||||
- Avoid changing persisted key names or cache key shapes in this pass.
|
||||
- Save broader API cleanups for a follow-up once modules are stable.
|
||||
@@ -1,88 +0,0 @@
|
||||
## File tree fetches
|
||||
|
||||
Make directory listing triggers explicit and minimal
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/components/file-tree.tsx` currently invokes `file.tree.list(path)` from a generic effect in each tree instance. Even with inflight guards, this pattern causes avoidable list calls and makes load behavior harder to reason about. This spec tightens fetch triggers.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Avoid redundant list invocations from passive rerenders
|
||||
- Fetch directory data only when needed (mount + expansion + explicit refresh)
|
||||
- Keep tree behavior unchanged for users
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Replacing recursive tree rendering with virtualization
|
||||
- Changing file-tree visual design
|
||||
- Backend/API changes for file listing
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Replace broad list effect with explicit triggers
|
||||
|
||||
- Load root path on mount.
|
||||
- For nested directories, list only when:
|
||||
- node is expanded, or
|
||||
- parent explicitly requests refresh.
|
||||
|
||||
2. Guard expansion-driven fetches
|
||||
|
||||
- Keep `file.tree.expand(path)` as the primary source of truth for expansion fetches.
|
||||
- Ensure passive rerenders do not retrigger `list(path)` calls for already loaded dirs.
|
||||
|
||||
3. Keep filter auto-expand behavior
|
||||
|
||||
- Preserve existing "allowed filter" directory auto-expansion.
|
||||
- Ensure auto-expanded directories still fetch exactly once unless force refresh occurs.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `file-tree.tsx` no longer calls `file.tree.list(path)` from an unscoped rerender effect.
|
||||
- Expanding a folder still loads its children correctly.
|
||||
- Filtering by `allowed` still opens and shows required parent directories.
|
||||
- No regressions in change/all tabs where `FileTree` is used.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Expand/collapse deep directory trees repeatedly.
|
||||
- Switch between "changes" and "all" tree tabs.
|
||||
- Open review, click files, verify tree stays responsive.
|
||||
- Optional instrumentation:
|
||||
- count list calls per user action and compare before/after.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: directories fail to load when expansion timing changes.
|
||||
- Mitigation: rely on `expand()` path and verify for root + nested nodes.
|
||||
- Risk: filter-driven auto-expand misses one level.
|
||||
- Mitigation: keep existing auto-expand iteration and add regression checks.
|
||||
@@ -1,87 +0,0 @@
|
||||
## Comments indexing
|
||||
|
||||
Avoid repeated flatten+sort for comment aggregates
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/comments.tsx` derives `all` by flattening all file comment arrays and sorting on every change. This is simple but can become expensive with many comments. We will maintain an indexed aggregate structure incrementally.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Keep `comments.list(file)` behavior unchanged
|
||||
- Make `comments.all()` retrieval near O(1) for reads
|
||||
- Preserve chronological ordering guarantees
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Persisting comments in a new schema
|
||||
- Adding new comment metadata fields
|
||||
- UI changes for comment display
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/comments.tsx`
|
||||
- Optional tests for comments context
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/ui/src/components/line-comment.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add aggregate index state
|
||||
|
||||
- Maintain `commentsByFile` (existing) plus an `allComments` array in chronological order.
|
||||
- Keep both updated through the same mutator paths.
|
||||
|
||||
2. Update mutators
|
||||
|
||||
- `add`: append new comment to file list and aggregate list.
|
||||
- `remove`: remove from file list and aggregate list by id/file.
|
||||
- `clear`: reset both structures and focus/active state.
|
||||
|
||||
3. Simplify selectors
|
||||
|
||||
- `list(file)` reads file list directly.
|
||||
- `all()` returns pre-indexed aggregate list without per-read flatten+sort.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `comments.all()` no longer flattens and sorts every reactive run.
|
||||
- Comment order stays chronological by `time`.
|
||||
- `add/remove/clear/focus/active` semantics remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Add multiple comments across different files.
|
||||
- Remove one comment and verify both file-level and global views update correctly.
|
||||
- Submit prompt (which clears comments) and verify reset behavior.
|
||||
- Optional unit test:
|
||||
- add/remove/clear keeps aggregate ordering and integrity.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: aggregate list and per-file lists diverge.
|
||||
- Mitigation: funnel all writes through centralized mutators; avoid direct store writes elsewhere.
|
||||
- Risk: ID collision edge cases.
|
||||
- Mitigation: keep UUID creation unchanged and remove by `file + id` pair.
|
||||
@@ -1,108 +0,0 @@
|
||||
## Server health and row dedupe
|
||||
|
||||
Unify server health checks and deduplicate server-row UI logic.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Server health logic is duplicated across multiple files, and server row rendering/truncation logic is repeated in both the status popover and server dialog. This creates drift risk and inconsistent behavior. This spec centralizes health checks and row rendering while preserving existing UX.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Introduce one shared server-health checker.
|
||||
- Use consistent timeout and error semantics in all server health call sites.
|
||||
- Deduplicate repeated server row truncation/tooltip behavior.
|
||||
- Keep current polling interval and status semantics unless explicitly changed.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No redesign of the status popover or server dialog.
|
||||
- No changes to server persistence model.
|
||||
- No broad refactor of unrelated status tabs (MCP/LSP/plugins).
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/components/dialog-select-server.tsx`
|
||||
- `packages/app/src/components/status-popover.tsx`
|
||||
- `packages/app/src/context/server.tsx`
|
||||
- New files under `packages/app/src/components/server/**` and/or `packages/app/src/utils/server-health.ts`
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `packages/app/src/components/terminal.tsx` (spec 15)
|
||||
- `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx` (specs 09/10)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Duplicate `checkHealth` implementation in:
|
||||
- `components/dialog-select-server.tsx`
|
||||
- `components/status-popover.tsx`
|
||||
- Similar health check logic in `context/server.tsx`.
|
||||
- Duplicate row truncation + resize listener logic in status and dialog server lists.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
1. Add shared health utility:
|
||||
|
||||
- `checkServerHealth(url, fetch, opts)`
|
||||
- one timeout strategy
|
||||
- one return shape: `{ healthy: boolean, version?: string }`
|
||||
|
||||
2. Add shared server row primitive:
|
||||
|
||||
- common rendering for status dot, truncated name/version handling, tooltip content
|
||||
- optional action slots for per-screen controls
|
||||
|
||||
3. Adopt utility and row primitive in both consumers.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Create `utils/server-health.ts` and migrate all health call sites.
|
||||
2. Create shared row component (`components/server/server-row.tsx`).
|
||||
3. Replace duplicated row logic in server dialog and status popover.
|
||||
4. Confirm polling and active/default server behavior still match existing UX.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Exactly one app-level server health check implementation remains.
|
||||
- Server row truncation/tooltip behavior is shared, not duplicated.
|
||||
- No regressions when switching active/default server.
|
||||
- Existing status dot semantics are preserved.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/status/status-popover.spec.ts`
|
||||
- `e2e/app/server-default.spec.ts`
|
||||
- Manual checks:
|
||||
- add/edit/remove server
|
||||
- blocked unhealthy server behavior
|
||||
- default server toggles and persistence
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep shared server row API minimal and composable.
|
||||
- Avoid introducing new global state for this refactor.
|
||||
- Prefer deterministic helper behavior over UI-specific branching inside the utility.
|
||||
@@ -1,104 +0,0 @@
|
||||
## Prompt input split
|
||||
|
||||
Modularize `prompt-input.tsx` without behavior changes
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/components/prompt-input.tsx` is a very large component that combines editor DOM parsing, popovers, history, drag/drop + paste uploads, worktree/session creation, optimistic messages, and send/abort flow. This spec splits it into focused modules so future changes are safer.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Reduce `prompt-input.tsx` complexity and file size
|
||||
- Extract cohesive logic into testable hooks/helpers
|
||||
- Keep runtime behavior and UX unchanged
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Replacing contenteditable editor approach
|
||||
- Major UX redesign of composer controls
|
||||
- API contract changes for prompt submission
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- New files under `packages/app/src/components/prompt-input/*`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/context/prompt.tsx` (except minor type-only imports if needed)
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Extract editor DOM helpers
|
||||
|
||||
- Move pure DOM/selection helpers into `prompt-input/editor-dom.ts`:
|
||||
- `createTextFragment`
|
||||
- `getNodeLength`
|
||||
- `getTextLength`
|
||||
- cursor get/set helpers
|
||||
|
||||
2. Extract history controller
|
||||
|
||||
- Move prompt history read/write/navigation logic into `prompt-input/history.ts` hook.
|
||||
- Keep existing persisted keys and history semantics unchanged.
|
||||
|
||||
3. Extract attachment interactions
|
||||
|
||||
- Move image/file paste + drag/drop + file-input attachment flows to `prompt-input/attachments.ts` hook.
|
||||
|
||||
4. Extract submit pipeline
|
||||
|
||||
- Move send/abort/optimistic message pipeline to `prompt-input/submit.ts` service/hook.
|
||||
- Keep existing error toasts, worktree handling, and rollback behavior.
|
||||
|
||||
5. Keep composition shell stable
|
||||
|
||||
- `PromptInput` component remains the integration shell that wires hooks + JSX.
|
||||
- Preserve exported component API and props.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `prompt-input.tsx` becomes primarily orchestration + view code.
|
||||
- Extracted modules contain the heavy imperative logic.
|
||||
- All existing behaviors remain intact:
|
||||
- slash and @ popovers
|
||||
- history up/down navigation
|
||||
- image attach/paste/drag-drop
|
||||
- shell mode submit/abort
|
||||
- optimistic message + rollback on failure
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual regression checklist:
|
||||
- type prompt, submit, stop, retry
|
||||
- use `/` command selection and `@` selector
|
||||
- history navigation with arrows
|
||||
- paste image, drag image, remove attachment
|
||||
- start in new session + worktree create path
|
||||
- failure path restores prompt and context comments
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: subtle ordering changes in submit rollback logic.
|
||||
- Mitigation: migrate logic mechanically first, then cleanup.
|
||||
- Risk: editor selection bugs after helper extraction.
|
||||
- Mitigation: keep existing cursor helpers unchanged and add focused manual checks.
|
||||
@@ -1,106 +0,0 @@
|
||||
## Runtime adapter type safety
|
||||
|
||||
Reduce unsafe casts at browser and third-party integration boundaries.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
Several integration points rely on `as any` or `unknown as` casts (terminal internals, speech recognition, add-on internals, generic trigger props). This spec introduces typed adapters and narrow interfaces to improve maintainability and make type errors actionable.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove or significantly reduce unsafe casts in scoped files.
|
||||
- Introduce explicit adapter interfaces around unstable third-party APIs.
|
||||
- Preserve behavior with no UX changes.
|
||||
- Improve maintainability of terminal and speech integrations.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No server health dedupe work (owned by spec 14).
|
||||
- No large architectural changes to terminal or speech subsystems.
|
||||
- No changes to business logic semantics.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/components/terminal.tsx`
|
||||
- `packages/app/src/utils/speech.ts`
|
||||
- `packages/app/src/addons/serialize.ts`
|
||||
- `packages/app/src/components/dialog-select-model.tsx`
|
||||
- New utility files under `packages/app/src/utils/**` related to adapter typing
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `components/dialog-select-server.tsx`, `components/status-popover.tsx`, `context/server.tsx` (spec 14)
|
||||
- `components/prompt-input.tsx` (spec 11)
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Explicit `as any` appears in `serialize.ts` and `speech.ts`.
|
||||
- Multiple `unknown as` casts in `terminal.tsx` for option/disposable access.
|
||||
- Generic trigger props in `dialog-select-model.tsx` use `as any` spread.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
1. Add narrow adapter types for third-party internals:
|
||||
|
||||
- terminal option setter/disposable handles
|
||||
- speech recognition constructor on `window`
|
||||
- serialize addon internal terminal buffer access
|
||||
|
||||
2. Introduce tiny helper guards/utilities:
|
||||
|
||||
- `isDisposable(value): value is { dispose(): void }`
|
||||
- `hasSetOption(value): value is { setOption(...): void }`
|
||||
|
||||
3. Replace broad casts with adapter functions and runtime checks.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Refactor terminal helpers (`setOption`, disposal cleanups) to typed guards.
|
||||
2. Refactor speech recognition window access to typed constructor lookup.
|
||||
3. Replace `serialize.ts` `as any` internals with explicit local interface.
|
||||
4. Remove `dialog-select-model.tsx` `as any` trigger props cast via stricter generic typing.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No `as any` remains in the scoped files (or document unavoidable cases inline).
|
||||
- `unknown as` usage in scoped files is minimized and justified.
|
||||
- Typecheck passes with no new suppression comments.
|
||||
- Runtime behavior remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Targeted e2e checks:
|
||||
- `e2e/terminal/terminal.spec.ts`
|
||||
- `e2e/models/model-picker.spec.ts`
|
||||
- Manual checks:
|
||||
- terminal open/connect/resize/cleanup
|
||||
- speech start/stop and interim/final behavior
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Prefer small typed wrapper functions over inline complex narrowing.
|
||||
- Keep adapter names explicit and local to their integration point.
|
||||
- If a cast cannot be removed safely, add a short comment describing why.
|
||||
@@ -1,107 +0,0 @@
|
||||
## i18n hardening and parity
|
||||
|
||||
Strengthen locale correctness and remove remaining hardcoded copy.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
The app has broad translation coverage but still has maintainability gaps: locale dictionaries are typed as `Partial`, some non-English dictionaries contain English values for specific keys, and a few user-facing strings are still hardcoded in components/pages. This spec hardens i18n guarantees and cleans up remaining drift.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Enforce stricter dictionary key parity across all app locales.
|
||||
- Remove known English fallback strings from non-English locale files.
|
||||
- Localize remaining hardcoded user-facing strings in scoped files.
|
||||
- Keep existing localization architecture (`useLanguage().t(...)`) intact.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No translation quality rewrite for all strings.
|
||||
- No locale expansion beyond existing languages.
|
||||
- No changes to non-user-facing log/diagnostic strings.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/src/context/language.tsx`
|
||||
- `packages/app/src/i18n/*.ts`
|
||||
- `packages/app/src/components/dialog-custom-provider.tsx`
|
||||
- `packages/app/src/pages/directory-layout.tsx`
|
||||
|
||||
This workstream must not edit:
|
||||
|
||||
- `pages/session.tsx`, `pages/layout.tsx`, `components/prompt-input.tsx`
|
||||
- server/terminal integration files owned by specs 14/15
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Locale files are large and manually maintained.
|
||||
- Non-English locales are typed with `Partial<Record<Keys, string>>`, which allows silent missing keys.
|
||||
- Known untranslated strings exist for keys like:
|
||||
- `command.session.previous.unseen`
|
||||
- `command.session.next.unseen`
|
||||
- Some user-facing strings remain hardcoded in scoped files.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
1. Tighten locale typing:
|
||||
|
||||
- Move from `Partial<Record<Keys, string>>` to stricter parity enforcement.
|
||||
- Keep `en.ts` as source-of-truth key set.
|
||||
|
||||
2. Fix known untranslated key values in non-English dictionaries.
|
||||
|
||||
3. Localize scoped hardcoded strings by adding translation keys and using `language.t(...)`.
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Add/adjust shared locale typing pattern for parity safety.
|
||||
2. Update all locale files to satisfy stricter typing.
|
||||
3. Translate known English carry-over keys in non-English dictionaries.
|
||||
4. Replace hardcoded copy in:
|
||||
|
||||
- `components/dialog-custom-provider.tsx`
|
||||
- `pages/directory-layout.tsx`
|
||||
|
||||
5. Run typecheck and parity checks.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Locale files enforce full key parity against `en` (compile-time).
|
||||
- No known English carry-over values remain for the targeted keys in non-English locales.
|
||||
- Scoped hardcoded user-facing strings are replaced with translation keys.
|
||||
- Typecheck passes.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Typecheck: `bun run typecheck` (from `packages/app`).
|
||||
- Grep sanity checks:
|
||||
- targeted keys no longer English in non-English locales
|
||||
- scoped files no longer contain hardcoded user-facing copy
|
||||
- Manual spot checks in at least 2 locales (for example: `de`, `zh`).
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep key naming consistent with existing conventions.
|
||||
- Avoid broad copy changes outside scoped files to reduce review surface.
|
||||
- If translation wording is uncertain, keep it simple and literal for now; quality passes can follow.
|
||||
@@ -1,82 +0,0 @@
|
||||
## Terminal cache scope
|
||||
|
||||
Clarify workspace-only terminal cache semantics
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/terminal.tsx` accepts `(dir, session)` but currently keys cache entries as `${dir}:${WORKSPACE_KEY}`. The behavior is workspace-scoped, but the API shape suggests session-scoped caching. This spec aligns naming and implementation to avoid confusion and future bugs.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Make terminal cache scope explicit (workspace-scoped)
|
||||
- Remove misleading unused session-keying surface
|
||||
- Preserve existing runtime behavior
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing terminal persistence behavior
|
||||
- Moving terminals to per-session isolation
|
||||
- UI changes to terminal tabs
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/terminal.tsx`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/session/session-sortable-terminal-tab.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Rename internals for clarity
|
||||
|
||||
- Update internal function names/variables from session-oriented to workspace-oriented where applicable.
|
||||
|
||||
2. Remove unused session cache-key parametering
|
||||
|
||||
- Simplify `load`/factory signatures so keying intent is explicit.
|
||||
- Keep key format workspace-only by directory.
|
||||
|
||||
3. Add inline documentation
|
||||
|
||||
- Add short comment near cache key creation clarifying why terminals are shared across sessions in the same workspace.
|
||||
|
||||
4. Keep behavior stable
|
||||
|
||||
- Ensure active terminal, tab order, clone/new/close behavior remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No unused session-derived cache key logic remains.
|
||||
- Code communicates workspace-scoped terminal lifecycle clearly.
|
||||
- No functional changes to terminal operations.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Create multiple terminals, navigate between sessions in same workspace, confirm state continuity.
|
||||
- Switch workspace directory, confirm separate terminal state.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: accidental behavior change to session-scoped terminals.
|
||||
- Mitigation: keep cache key unchanged; refactor naming/signatures only.
|
||||
@@ -1,101 +0,0 @@
|
||||
## Unit test foundation
|
||||
|
||||
Establish reliable unit coverage for core app logic.
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app` is still e2e-first, but recent refactoring added a first wave of active source-unit tests (session helpers/scroll spy, prompt-input modules, file-tree, comments/layout/terminal/file context, and scoped-cache). This spec focuses on turning that momentum into a stable, explicit unit-test baseline in CI/local and unblocking the remaining skipped legacy suites.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Add a clear unit-test command for app source tests.
|
||||
- Unskip and stabilize existing skipped unit tests.
|
||||
- Add fast tests for high-value pure logic.
|
||||
- Keep unit suite independent of full e2e environment.
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No replacement of e2e tests.
|
||||
- No broad product-code refactors unless required to make logic testable.
|
||||
- No flaky browser-automation tests added here.
|
||||
|
||||
---
|
||||
|
||||
### Parallel ownership (important)
|
||||
|
||||
This workstream owns:
|
||||
|
||||
- `packages/app/package.json` (test scripts only)
|
||||
- `packages/app/happydom.ts` (if harness tweaks are needed)
|
||||
- `packages/app/src/**/*.test.ts`
|
||||
- `packages/app/src/**/*.test.tsx`
|
||||
|
||||
This workstream should avoid editing product code files owned by other specs, unless a tiny testability export is strictly required.
|
||||
|
||||
---
|
||||
|
||||
### Current state
|
||||
|
||||
- Active unit coverage now exists across several `src/**/*.test.*` files (including context, pages/session, components/prompt-input, and utils).
|
||||
- Remaining skipped legacy suites:
|
||||
- `src/context/layout-scroll.test.ts` (`test.skip`)
|
||||
- `src/addons/serialize.test.ts` (`describe.skip`)
|
||||
- `package.json` scripts still focus on Playwright e2e and do not expose a dedicated `test:unit` entrypoint.
|
||||
|
||||
---
|
||||
|
||||
### Proposed approach
|
||||
|
||||
1. Add dedicated unit-test script(s), for example:
|
||||
|
||||
- `test:unit` using Bun test + happydom preload where needed.
|
||||
|
||||
2. Unskip and stabilize remaining skipped legacy tests:
|
||||
|
||||
- make `layout-scroll.test.ts` deterministic
|
||||
- enable a reliable subset of `serialize.test.ts` (or split smoke vs heavy integration cases)
|
||||
|
||||
3. Add/expand fast unit tests for high-value pure logic not yet covered:
|
||||
|
||||
- keybind parsing/formatting/matching (`context/command.tsx` exports)
|
||||
- worktree state machine (`utils/worktree.ts`)
|
||||
|
||||
---
|
||||
|
||||
### Phased steps
|
||||
|
||||
1. Wire `test:unit` in `package.json`.
|
||||
2. Make existing skipped tests runnable and stable.
|
||||
3. Add at least 2 new unit test files for core pure logic.
|
||||
4. Ensure unit suite can run standalone without Playwright server setup.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `bun run test:unit` exists and passes locally.
|
||||
- No full-file `describe.skip`/`test.skip` remains in `packages/app/src/**/*.test.*` (unless documented as intentionally quarantined with reason).
|
||||
- Unit suite includes meaningful assertions for keybind + worktree logic.
|
||||
- Runtime for unit suite remains fast (target: under 15 seconds locally, excluding first install).
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Run: `bun run test:unit`.
|
||||
- Run: `bun run typecheck`.
|
||||
- Verify unit tests can execute without starting full app/backend servers.
|
||||
|
||||
---
|
||||
|
||||
### Handoff notes
|
||||
|
||||
- Keep tests implementation-focused, not duplicated business logic.
|
||||
- Avoid mocks where practical; prefer real small-scope code paths.
|
||||
- If integration-heavy serialize cases remain flaky, separate them into a clearly named non-default test target.
|
||||
@@ -1,51 +0,0 @@
|
||||
## Parallel workstream map
|
||||
|
||||
Use this as the assignment sheet for running multiple agents at once.
|
||||
|
||||
---
|
||||
|
||||
### Workstreams
|
||||
|
||||
1. `specs/09-session-page-decomposition.md`
|
||||
2. `specs/10-layout-page-decomposition.md`
|
||||
3. `specs/11-prompt-input-and-optimistic-state.md`
|
||||
4. `specs/12-global-sync-domain-split.md`
|
||||
5. `specs/13-file-context-domain-split.md`
|
||||
6. `specs/14-server-health-and-row-dedupe.md`
|
||||
7. `specs/15-runtime-adapter-type-safety.md`
|
||||
8. `specs/16-i18n-hardening-and-parity.md`
|
||||
9. `specs/17-unit-test-foundation.md`
|
||||
|
||||
---
|
||||
|
||||
### File-ownership matrix
|
||||
|
||||
| Spec | Primary ownership | Avoid editing |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 09 | `pages/session.tsx`, `pages/session/**` | `pages/layout.tsx`, `components/prompt-input.tsx`, `context/global-sync.tsx`, `context/file.tsx` |
|
||||
| 10 | `pages/layout.tsx`, `pages/layout/**` | `pages/session.tsx`, `components/prompt-input.tsx`, `context/global-sync.tsx` |
|
||||
| 11 | `components/prompt-input.tsx`, `components/prompt-input/**`, `context/sync.tsx` (optimistic API only) | `pages/session.tsx`, `pages/layout.tsx`, `context/global-sync.tsx`, `context/file.tsx` |
|
||||
| 12 | `context/global-sync.tsx`, `context/global-sync/**` | `context/file.tsx`, `components/prompt-input.tsx`, page files |
|
||||
| 13 | `context/file.tsx`, `context/file/**`, `utils/scoped-cache.ts` (only when file-view cache extraction needs it) | `context/global-sync.tsx`, `components/prompt-input.tsx`, page files |
|
||||
| 14 | `components/dialog-select-server.tsx`, `components/status-popover.tsx`, `context/server.tsx`, shared server utility/component | terminal/speech/serialize files |
|
||||
| 15 | `components/terminal.tsx`, `utils/speech.ts`, `addons/serialize.ts`, `components/dialog-select-model.tsx`, adapter utilities | server status/dialog/context files |
|
||||
| 16 | `context/language.tsx`, `i18n/*.ts`, `components/dialog-custom-provider.tsx`, `pages/directory-layout.tsx` | major page/context refactors |
|
||||
| 17 | `package.json` (test scripts), `happydom.ts`, `src/**/*.test.*` | product code files in other specs unless strictly needed |
|
||||
|
||||
---
|
||||
|
||||
### Recommended execution order (if all start together)
|
||||
|
||||
- Start all 9 in parallel.
|
||||
- Merge low-conflict streams first: 12, 13, 14, 15, 16, 17.
|
||||
- Then merge 09, 10, 11 (largest diff sizes and highest rebase probability).
|
||||
|
||||
---
|
||||
|
||||
### Integration checkpoint
|
||||
|
||||
After all streams merge, run a full verification pass in `packages/app`:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun run test:unit` (from spec 17)
|
||||
- targeted e2e smoke for session/layout/prompt/server/terminal flows
|
||||
@@ -1,59 +0,0 @@
|
||||
## Parallel agent plan
|
||||
|
||||
Execution map for session-page improvement concerns
|
||||
|
||||
---
|
||||
|
||||
### New specs added
|
||||
|
||||
- `specs/09-session-page-hot-paths.md`
|
||||
- `specs/10-file-content-eviction-accounting.md`
|
||||
- `specs/11-layout-view-tabs-reactivity.md`
|
||||
- `specs/12-session-context-metrics-shared.md`
|
||||
- `specs/13-file-tree-fetch-discipline.md`
|
||||
- `specs/14-comments-aggregation-index.md`
|
||||
- `specs/15-prompt-input-modularization.md`
|
||||
- `specs/16-terminal-cache-key-clarity.md`
|
||||
|
||||
---
|
||||
|
||||
### Existing related specs
|
||||
|
||||
- `specs/04-scroll-spy-optimization.md` (session scroll-spy concern)
|
||||
- `specs/05-modularize-and-dedupe.md` (broad modularization roadmap)
|
||||
|
||||
---
|
||||
|
||||
### Parallel-safe batching
|
||||
|
||||
Batch A (run one at a time, shared `session.tsx` surface):
|
||||
|
||||
- `specs/09-session-page-hot-paths.md`
|
||||
- `specs/04-scroll-spy-optimization.md`
|
||||
|
||||
Batch B (parallel with each other and with Batch A):
|
||||
|
||||
- `specs/10-file-content-eviction-accounting.md`
|
||||
- `specs/11-layout-view-tabs-reactivity.md`
|
||||
- `specs/12-session-context-metrics-shared.md`
|
||||
- `specs/13-file-tree-fetch-discipline.md`
|
||||
- `specs/14-comments-aggregation-index.md`
|
||||
- `specs/15-prompt-input-modularization.md`
|
||||
- `specs/16-terminal-cache-key-clarity.md`
|
||||
|
||||
Batch C (broad follow-up after focused specs land):
|
||||
|
||||
- `specs/05-modularize-and-dedupe.md`
|
||||
|
||||
---
|
||||
|
||||
### Suggested assignment
|
||||
|
||||
1. Agent A: `specs/09-session-page-hot-paths.md`
|
||||
2. Agent B: `specs/10-file-content-eviction-accounting.md`
|
||||
3. Agent C: `specs/11-layout-view-tabs-reactivity.md`
|
||||
4. Agent D: `specs/12-session-context-metrics-shared.md`
|
||||
5. Agent E: `specs/13-file-tree-fetch-discipline.md`
|
||||
6. Agent F: `specs/14-comments-aggregation-index.md`
|
||||
7. Agent G: `specs/15-prompt-input-modularization.md`
|
||||
8. Agent H: `specs/16-terminal-cache-key-clarity.md`
|
||||
@@ -1,196 +0,0 @@
|
||||
## Performance roadmap
|
||||
|
||||
Sequenced delivery plan for app scalability + maintainability
|
||||
|
||||
---
|
||||
|
||||
### Objective
|
||||
|
||||
Deliver the top 5 app improvements (performance + long-term flexibility) in a safe, incremental sequence that:
|
||||
|
||||
- minimizes regression risk
|
||||
- keeps changes reviewable (small PRs)
|
||||
- provides escape hatches (flags / caps)
|
||||
- validates improvements with targeted measurements
|
||||
|
||||
This roadmap ties together:
|
||||
|
||||
- `specs/01-persist-payload-limits.md`
|
||||
- `specs/02-cache-eviction.md`
|
||||
- `specs/03-request-throttling.md`
|
||||
- `specs/04-scroll-spy-optimization.md`
|
||||
- `specs/05-modularize-and-dedupe.md`
|
||||
|
||||
---
|
||||
|
||||
### Guiding principles
|
||||
|
||||
- Prefer “guardrails first”: add caps/limits and do no harm, then optimize.
|
||||
- Always ship behind flags if behavior changes (especially persistence and eviction).
|
||||
- Optimize at chokepoints (SDK call wrappers, storage wrappers, scroll-spy module) instead of fixing symptoms at every call site.
|
||||
- Make “hot paths” explicitly measurable in dev (e.g. via `packages/app/src/utils/perf.ts`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Baseline + flags (prep)
|
||||
|
||||
**Goal:** make later changes safe to land and easy to revert.
|
||||
|
||||
**Deliverables**
|
||||
|
||||
- Feature-flag plumbing for:
|
||||
- persistence payload limits (`persist.payloadLimits`)
|
||||
- request debouncing/latest-only (`requests.*`)
|
||||
- cache eviction (`cache.eviction.*`)
|
||||
- optimized scroll spy (`session.scrollSpyOptimized`)
|
||||
- shared scoped cache (`scopedCache.shared`)
|
||||
- Dev-only counters/logs for:
|
||||
- persist oversize detections
|
||||
- request aborts/stale drops
|
||||
- eviction counts and retained sizes
|
||||
- scroll-spy compute time per second
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Flags exist but default “off” for behavior changes.
|
||||
- No user-visible behavior changes.
|
||||
|
||||
**Effort / risk**: `S–M` / low
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Stop the worst “jank generators” (storage + request storms)
|
||||
|
||||
**Goal:** remove the highest-frequency sources of main-thread blocking and redundant work.
|
||||
|
||||
**Work items**
|
||||
|
||||
- Implement file search debounce + stale-result protection
|
||||
- Spec: `specs/03-request-throttling.md`
|
||||
- Start with file search only (lowest risk, easy to observe).
|
||||
- Add persistence payload size checks + warnings (no enforcement yet)
|
||||
- Spec: `specs/01-persist-payload-limits.md`
|
||||
- Focus on detecting oversized keys and preventing repeated write attempts.
|
||||
- Ship prompt-history “strip image dataUrl” behind a flag
|
||||
- Spec: `specs/01-persist-payload-limits.md`
|
||||
- Keep image metadata placeholders so UI remains coherent.
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Fast typing in file search generates at most 1 request per debounce window.
|
||||
- Oversize persisted keys are detected and do not cause repeated blocking writes.
|
||||
- Prompt history reload does not attempt to restore base64 `dataUrl` on web when flag enabled.
|
||||
|
||||
**Effort / risk**: `M` / low–med
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Bound memory growth (in-memory eviction)
|
||||
|
||||
**Goal:** stabilize memory footprint for long-running sessions and “project hopping”.
|
||||
|
||||
**Work items**
|
||||
|
||||
- Introduce shared LRU/TTL cache helper
|
||||
- Spec: `specs/02-cache-eviction.md`
|
||||
- Apply eviction to file contents cache first
|
||||
- Spec: `specs/02-cache-eviction.md`
|
||||
- Pin open tabs / active file to prevent flicker.
|
||||
- Add conservative eviction for global-sync per-directory child stores
|
||||
- Spec: `specs/02-cache-eviction.md`
|
||||
- Ensure evicted children are fully disposed.
|
||||
- (Optional) session/message eviction if memory growth persists after the above
|
||||
- Spec: `specs/02-cache-eviction.md`
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Opening many files does not continuously increase JS heap without bound.
|
||||
- Switching across many directories does not keep all directory stores alive indefinitely.
|
||||
- Eviction never removes currently active session/file content.
|
||||
|
||||
**Effort / risk**: `M–L` / med
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Large session scroll scalability (scroll spy)
|
||||
|
||||
**Goal:** keep scrolling smooth as message count increases.
|
||||
|
||||
**Work items**
|
||||
|
||||
- Extract scroll-spy logic into a dedicated module (no behavior change)
|
||||
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||
- Implement IntersectionObserver tracking behind flag
|
||||
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||
- Add binary search fallback for non-observer environments
|
||||
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Scroll handler no longer calls `querySelectorAll('[data-message-id]')` on every scroll tick.
|
||||
- Long sessions (hundreds of messages) maintain smooth scrolling.
|
||||
- Active message selection remains stable during streaming/layout shifts.
|
||||
|
||||
**Effort / risk**: `M` / med
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — “Make it easy to keep fast” (modularity + dedupe)
|
||||
|
||||
**Goal:** reduce maintenance cost and make future perf work cheaper.
|
||||
|
||||
**Work items**
|
||||
|
||||
- Introduce shared scoped-cache utility and adopt in one low-risk area
|
||||
- Spec: `specs/05-modularize-and-dedupe.md`
|
||||
- Incrementally split mega-components (one PR per extraction)
|
||||
- Spec: `specs/05-modularize-and-dedupe.md`
|
||||
- Prioritize extracting:
|
||||
- session scroll/backfill logic
|
||||
- prompt editor model/history
|
||||
- layout event/shortcut wiring
|
||||
- Remove duplicated patterns after confidence + one release cycle
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Each mega-file drops below a target size (suggestion):
|
||||
- `session.tsx` < ~800 LOC
|
||||
- `prompt-input.tsx` < ~900 LOC
|
||||
- “Scoped cache” has a single implementation used across contexts.
|
||||
- Future perf fixes land in isolated modules with minimal cross-cutting change.
|
||||
|
||||
**Effort / risk**: `L` / med–high
|
||||
|
||||
---
|
||||
|
||||
### Recommended PR slicing (keeps reviews safe)
|
||||
|
||||
- PR A: add request helpers + file search debounce (flagged)
|
||||
- PR B: persist size detection + logs (no behavior change)
|
||||
- PR C: prompt history strip images (flagged)
|
||||
- PR D: cache helper + file content eviction (flagged)
|
||||
- PR E: global-sync child eviction (flagged)
|
||||
- PR F: scroll-spy extraction (no behavior change)
|
||||
- PR G: optimized scroll-spy implementation (flagged)
|
||||
- PR H+: modularization PRs (small, mechanical refactors)
|
||||
|
||||
---
|
||||
|
||||
### Rollout strategy
|
||||
|
||||
- Keep defaults conservative and ship flags “off” first.
|
||||
- Enable flags internally (dev builds) to gather confidence.
|
||||
- Flip defaults in this order:
|
||||
1. file search debounce
|
||||
2. prompt-history image stripping
|
||||
3. file-content eviction
|
||||
4. global-sync child eviction
|
||||
5. optimized scroll-spy
|
||||
|
||||
---
|
||||
|
||||
### Open questions
|
||||
|
||||
- What are acceptable defaults for storage caps and cache sizes for typical OpenCode usage?
|
||||
- Does the SDK support `AbortSignal` end-to-end for cancellation, or do we rely on stale-result dropping?
|
||||
- Should web and desktop persistence semantics be aligned (even if desktop has async storage available)?
|
||||
@@ -1,234 +0,0 @@
|
||||
From 90904222b6f8c86a6d0a8ebed9661950f632a4e8 Mon Sep 17 00:00:00 2001
|
||||
From: OpenCode Bot <opencode@sst.dev>
|
||||
Date: Wed, 11 Feb 2026 18:44:27 +0000
|
||||
Subject: [PATCH] add square logo variants to brand page
|
||||
|
||||
---
|
||||
.../asset/brand/opencode-logo-dark-square.png | Bin 0 -> 697 bytes
|
||||
.../asset/brand/opencode-logo-dark-square.svg | 18 ++++++
|
||||
.../brand/opencode-logo-light-square.png | Bin 0 -> 697 bytes
|
||||
.../brand/opencode-logo-light-square.svg | 18 ++++++
|
||||
.../preview-opencode-logo-dark-square.png | Bin 0 -> 1477 bytes
|
||||
.../preview-opencode-logo-light-square.png | Bin 0 -> 1467 bytes
|
||||
.../console/app/src/routes/brand/index.tsx | 60 ++++++++++++++++++
|
||||
7 files changed, 96 insertions(+)
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-dark-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-light-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
create mode 100644 packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark-square.png b/packages/console/app/src/asset/brand/opencode-logo-dark-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..673c7e3a20f917fae56719ed1c35b4614ecd5f53
|
||||
GIT binary patch
|
||||
literal 697
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV2S`?CT5_>VU7ZSAjOjI=<CS9u(6-}Pa-RjuaN8!
|
||||
z<jcTNrN+R}(89p*3n<j^f`OsbfPvvv0t1893<d`Af;qbaZGaLy0X`wFK>FjGH{Nb;
|
||||
zK*kCWpQS*Gu_VYZn8D%MjWiG^$=lt9p@UV{1IXbl@Q5sCU=ULUVMfm&l@CBc_7YED
|
||||
zSN2y-+(O#2cjOjy1NC%xx;TbZ+<JS?ke5M0fMtVmr)P-K<_$G=ESI!0Hc3x^;^R#P
|
||||
z@VYyUYYSCCTI00A1HzVGb*Dn;c)vb-jcFpozT1*PW*Wd~QY~?fC`m~yNwrEYN(E93
|
||||
zMg~S^x&}tNhK3=A7FH&PRz^nJ1_o9J26?+;7NKa!%}>cptHiBA{`nI*paup{S3j3^
|
||||
HP6<r_YMlsn
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
new file mode 100644
|
||||
index 0000000..6a67f62
|
||||
--- /dev/null
|
||||
+++ b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
@@ -0,0 +1,18 @@
|
||||
+<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+<g transform="translate(30, 0)">
|
||||
+<g clip-path="url(#clip0_1401_86283)">
|
||||
+<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
+<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
+</mask>
|
||||
+<g mask="url(#mask0_1401_86283)">
|
||||
+<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
+<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
+</g>
|
||||
+</g>
|
||||
+</g>
|
||||
+<defs>
|
||||
+<clipPath id="clip0_1401_86283">
|
||||
+<rect width="240" height="300" fill="white"/>
|
||||
+</clipPath>
|
||||
+</defs>
|
||||
+</svg>
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-light-square.png b/packages/console/app/src/asset/brand/opencode-logo-light-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..5c710474abc4668504cb9678da1de6ad33458af9
|
||||
GIT binary patch
|
||||
literal 697
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV2S`?CT5_>VU7ZSAjOjI=<CS9u(6-}Pa-RjuaN8!
|
||||
z<jcTNrN+R}(89p*3n<j^f`OsbfPvvv0t1893<d`Af;qbaZGaLy0X`wFKw42w?)<s4
|
||||
zK*kyVm4AQ~V@Z%-FoVOh8)+a;lDE4HLkFv@2av;A;1OBOz#ygy!i=6lDj$G?>?NMQ
|
||||
zuI#UvxP`Q3@5n9a2I}eXba4!+xb^m&Auof10LupBPR|gd%^Pa$ST1R0Y?7Y-#K)To
|
||||
z;B|Kx*A}XPw8m+J2ZSxX>Q05w@qT^w8q-9EeYYip%rt<}q*~${QIe8al4_M)lnSI6
|
||||
zj0}v-bPbGj4GlvKEv!rot&EJc4GgRd4DxoxEJD$co1c=IR*74K{PQPrKn)C@u6{1-
|
||||
HoD!M<*)j+`
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-light-square.svg b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
new file mode 100644
|
||||
index 0000000..a738ad8
|
||||
--- /dev/null
|
||||
+++ b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
@@ -0,0 +1,18 @@
|
||||
+<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+<g transform="translate(30, 0)">
|
||||
+<g clip-path="url(#clip0_1401_86274)">
|
||||
+<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
+<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
+</mask>
|
||||
+<g mask="url(#mask0_1401_86274)">
|
||||
+<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
+<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
+</g>
|
||||
+</g>
|
||||
+</g>
|
||||
+<defs>
|
||||
+<clipPath id="clip0_1401_86274">
|
||||
+<rect width="240" height="300" fill="white"/>
|
||||
+</clipPath>
|
||||
+</defs>
|
||||
+</svg>
|
||||
diff --git a/packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png b/packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..604ad7aa7a87c71d4a3972f18da4044e53f745fe
|
||||
GIT binary patch
|
||||
literal 1477
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$<hK>E)e-c@Ne1&9>
|
||||
zAYTTCDm4a%h86~fUqGRT7Yq!g1`G_Z5*Qe)W-u^_7tGleXakf83GfMV1=1hiyun28
|
||||
zU%gs1cdnz2jklZIqq}#b!@|5=U4d+%%7=IEM1_VzxRD_to-Qsx1sTc7KrJ6$zXlo@
|
||||
zFuxY$Hi42LzhDLii5Gug>4^i8#NSshUI=lk@B-x+lf2zs7&=&GJ%Aj}0*}aI1_m)z
|
||||
z5N7lYQuzQBWH0gbb!C6W#4V()m9m%>=ouDUPZ!6Kid%0la{4g^2(UVS^9^9Ux#z#*
|
||||
zUM9{NA)j{pe@M$Rs$BIl=U=S8$$a1WzrRdo{gl+0z}h6r5vC9^6c`^Bx}VO;+bO}5
|
||||
zvNP)ZgKMjwCMdj@`|<2Y_0Pt}1ZL)gY-~-uJS@@@9A*XrISC3k4mfNWo)RazbGPm0
|
||||
z-*LChSmMOL4?kJISKi=fE3%ne|KQy6WQCxi5SK$J*`YY~T7$y$*OKq5Bzd2d?Vs~N
|
||||
z@B8fph5p+U-+hzde)4_q^MCH|=dNhx2_G663hFce=0A)TEv>6k&v^?1%NErV*NBpo
|
||||
z#FA92<f2p{#b9J$WTtCiq-$sxVrXGyVr*q(scm3jWnhruaU&H)LvDUbW?Cg~4U(b>
|
||||
RH-Q=$JYD@<);T3K0RY|)#{mEU
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png b/packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..3964d8528440323730053e56f9d957539937b99d
|
||||
GIT binary patch
|
||||
literal 1467
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$<hK>E)e-c@Ne1&9>
|
||||
zAYTTCDm4a%h86~fUqGRT7Yq!g1`G_Z5*Qe)W-u^_7tGleXakh+3-AeX1=5Oga+pX-
|
||||
zUcR`n@W%CP=g*zh*VWm(dpD2)RHh&+YpAEUZ|@!;cmKY<7tWv4*U?$OZY@wf(5P=q
|
||||
zw@e07{3Stt!3+!%FaEyL69*!Rzpq|wUamVGD8-oM?e4<R!7A$k<Zu>vL>4nJh^c}w
|
||||
zqi2xH2cRH(iKnkC`zt1HA#JUc#jHSIuvmGzIEGZ*dV8@wmnlGi)v?HEMncg4sk@lD
|
||||
zIaT_<lxtU%8_(QxrNqqsn)KQ9%h&DxCN=JfRETIyU~LlR2pb9vn-fWv%cJ$!m?PI+
|
||||
zGv4tv%TnURa`rlle{ppQ3O5coY-nsuU}iqZ#@58k!y+xgVP+tZGdv|e#VtQu_MLV6
|
||||
z?L5Ea#y9-;OWOY?C_F#SfA^^jN9a(9$sv^JP@HNicjD`}`}Y4=-!3(o@cVI9<8Hfm
|
||||
z&5bti(|7$Y)|v3Q^Zn-UpA;4kk?aKV*|pOO`<V}&_gU>d_YAOLQ7v(eC`m~yNwrEY
|
||||
zN(E93Mg~S^x&}tNhK3=A7FH(4Rz{ZE1_o9J1{oeVQc*PI=BH$)RpQnlDVlH-sDZ)L
|
||||
L)z4*}Q$iB}B`L3|
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/routes/brand/index.tsx b/packages/console/app/src/routes/brand/index.tsx
|
||||
index eda3c84..9140462 100644
|
||||
--- a/packages/console/app/src/routes/brand/index.tsx
|
||||
+++ b/packages/console/app/src/routes/brand/index.tsx
|
||||
@@ -7,18 +7,24 @@ import { useI18n } from "~/context/i18n"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
|
||||
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
|
||||
+import previewLogoLightSquare from "../../asset/brand/preview-opencode-logo-light-square.png"
|
||||
+import previewLogoDarkSquare from "../../asset/brand/preview-opencode-logo-dark-square.png"
|
||||
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
|
||||
import previewWordmarkDark from "../../asset/brand/preview-opencode-wordmark-dark.png"
|
||||
import previewWordmarkSimpleLight from "../../asset/brand/preview-opencode-wordmark-simple-light.png"
|
||||
import previewWordmarkSimpleDark from "../../asset/brand/preview-opencode-wordmark-simple-dark.png"
|
||||
import logoLightPng from "../../asset/brand/opencode-logo-light.png"
|
||||
import logoDarkPng from "../../asset/brand/opencode-logo-dark.png"
|
||||
+import logoLightSquarePng from "../../asset/brand/opencode-logo-light-square.png"
|
||||
+import logoDarkSquarePng from "../../asset/brand/opencode-logo-dark-square.png"
|
||||
import wordmarkLightPng from "../../asset/brand/opencode-wordmark-light.png"
|
||||
import wordmarkDarkPng from "../../asset/brand/opencode-wordmark-dark.png"
|
||||
import wordmarkSimpleLightPng from "../../asset/brand/opencode-wordmark-simple-light.png"
|
||||
import wordmarkSimpleDarkPng from "../../asset/brand/opencode-wordmark-simple-dark.png"
|
||||
import logoLightSvg from "../../asset/brand/opencode-logo-light.svg"
|
||||
import logoDarkSvg from "../../asset/brand/opencode-logo-dark.svg"
|
||||
+import logoLightSquareSvg from "../../asset/brand/opencode-logo-light-square.svg"
|
||||
+import logoDarkSquareSvg from "../../asset/brand/opencode-logo-dark-square.svg"
|
||||
import wordmarkLightSvg from "../../asset/brand/opencode-wordmark-light.svg"
|
||||
import wordmarkDarkSvg from "../../asset/brand/opencode-wordmark-dark.svg"
|
||||
import wordmarkSimpleLightSvg from "../../asset/brand/opencode-wordmark-simple-light.svg"
|
||||
@@ -135,6 +141,60 @@ export default function Brand() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+ <div>
|
||||
+ <img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
|
||||
+ <div data-component="actions">
|
||||
+ <button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
|
||||
+ PNG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ <button onClick={() => downloadFile(logoLightSquareSvg, "opencode-logo-light-square.svg")}>
|
||||
+ SVG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
|
||||
+ <div data-component="actions">
|
||||
+ <button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
|
||||
+ PNG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ <button onClick={() => downloadFile(logoDarkSquareSvg, "opencode-logo-dark-square.svg")}>
|
||||
+ SVG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
<div>
|
||||
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
--
|
||||
2.39.5
|
||||
|
||||
Reference in New Issue
Block a user