Compare commits

..
Author SHA1 Message Date
jlongster d5e3dac5b2 fix(tui): soften block tool errors 2026-08-21 02:25:24 +00:00
Kit Langton 4db272ff64 fix(core): steer manual compaction by default (#43724) 2026-08-21 01:51:36 +00:00
opencode-agent[bot]andrekram1-node fdc4fd8268 refactor(core): remove dead AI SDK ID stripping (#43741)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-20 20:44:53 -05:00
Luke Parker 67e87f534e fix(app): speed up cold home navigation (#43738) 2026-08-21 00:56:34 +00:00
Aiden Cline 2938ac3298 fix(core): prevent shell eviction loop (#43650) 2026-08-20 19:25:06 -05:00
opencode-agent[bot]andHona 749d24ebc0 fix(client): authenticate PTY websocket connections (#43735)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-21 00:02:54 +00:00
Luke Parker 384cff3768 refactor(app): establish session vertical slice (#43637) 2026-08-21 09:16:07 +10:00
Aiden Cline 5970537a8a feat(plugin): expose session selection methods (#43718) 2026-08-20 18:04:43 -05:00
Simon Klee 58f909d5b9 chore: upgrade opentui 0.5.6 (#43725) 2026-08-20 22:13:12 +00:00
Dax cc15c2a488 feat(posts): add Astro posts site (#43720) 2026-08-20 16:37:28 -04:00
Kit Langton ebc2504ef3 refactor(core): route title and compaction through shared model requests (#43555) 2026-08-20 16:46:57 +00:00
Aiden Cline c33c9bf2b9 fix(core): preserve background shell status (#43663) 2026-08-20 11:01:15 -05:00
Kit Langton 2970b7a6a8 fix(core): settle foreign typed tool failures instead of dropping them (#43576) 2026-08-20 11:56:35 -04:00
Kit Langton 82d2c6133e fix(tui): stop registering one resize listener per transcript row (#43562) 2026-08-20 11:55:37 -04:00
Dax Raad 22f2604ffa fix(core): preserve unicode in compaction truncation 2026-08-20 11:52:42 -04:00
Shoubhit Dash a71884dfdf test(ai): record responses websocket flows (#43660) 2026-08-20 21:14:37 +05:30
Kit Langton 6f629c2a9d refactor(core): use Latch for shell output gate (#43573) 2026-08-20 11:38:01 -04:00
Kit Langton 4651bd15de refactor(server): use Latch for shutdown gate (#43571) 2026-08-20 11:37:35 -04:00
Kit Langton ad7ebe84a0 refactor(tui): use Latch for renderer shutdown gate (#43572) 2026-08-20 11:37:22 -04:00
Kit Langton 38eeed56cd refactor(core): use Latch for MCP startup gate (#43570) 2026-08-20 11:37:19 -04:00
Kit Langton b453c2016c refactor(core): use Latch for plugin supervisor ready gate (#43569) 2026-08-20 11:37:14 -04:00
192 changed files with 4879 additions and 4958 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+37
View File
@@ -0,0 +1,37 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+31 -17
View File
@@ -592,9 +592,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"@opentui/core": ">=0.5.6",
"@opentui/keymap": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1094,9 +1094,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2118,27 +2118,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
"@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6" }, "peerDependencies": { "@opentui/react": "0.5.6", "@opentui/solid": "0.5.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1lR1aFRSgHCwN31YOHDQQVSqz1YNN7DsoIvWHjEiQJ12kTxHg9G7Q1HHFJttM838RWsNOvW6gBAhOr403VL8rg=="],
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
"@opentui/solid": ["@opentui/solid@0.5.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kxmrQS31sgziW/qZDuFYuiT3CvcUQg9ZQA1qKCEb5mBoInur4fnECOdTaM2S8c3cidcV0z2yu41NP1Dypkl+BQ=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -6882,6 +6882,8 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"ow/dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
@@ -8092,6 +8094,17 @@
"nitro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -9432,6 +9445,7 @@
"nitro/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+3 -3
View File
@@ -48,9 +48,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -0,0 +1,100 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -0,0 +1,119 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -0,0 +1,129 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
@@ -0,0 +1,216 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
+10 -2
View File
@@ -1,5 +1,7 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -16,7 +18,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
@@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
})
})
await page.route("**/pty*", (route) =>
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -1,7 +1,7 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
import { observeElementOffsetReconnectAware } from "../../../src/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
+1 -1
View File
@@ -36,7 +36,7 @@ import { ServerProvider } from "./context/server"
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/pages/session"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
@@ -12,8 +12,8 @@ import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
@@ -24,7 +24,7 @@ import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { createSessionTabs } from "@/pages/session/helpers"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
@@ -1,5 +0,0 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { NewSessionView } from "./session-new-view"
+14 -27
View File
@@ -15,9 +15,7 @@ import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalConnectToken } from "@/utils/terminal-connect-token"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -179,7 +177,6 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory
const url = serverSDK.url
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -531,14 +528,6 @@ export const Terminal = (props: TerminalProps) => {
})
}
const connectToken = async () => {
const result = await terminalConnectToken({ url, id, directory })
if (result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status }))
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@@ -562,23 +551,21 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
const ticket = await connectToken().catch((err) => {
fail(err)
return undefined
})
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
url,
id,
directory,
const socket = await serverSDK.pty
.connect({
ptyID: id,
location: { directory },
cursor: seek,
ticket,
}),
)
socket.binaryType = "arraybuffer"
})
.catch((err) => {
fail(err)
return undefined
})
if (!socket || once.value) return
if (disposed) {
socket.close(1000)
return
}
ws = socket
const handleOpen = () => {
@@ -20,7 +20,7 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
describe("Home V2 session index", () => {
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
const calls: Array<{ cursor?: string }> = []
const calls: Array<{ cursor?: string; parentID: null }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: first, cursor: { next: "next" } }
@@ -29,6 +29,7 @@ describe("Home V2 session index", () => {
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
expect(calls.every((call) => call.parentID === null)).toBe(true)
})
test("keeps only visible roots", () => {
@@ -6,7 +6,12 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
input: {
limit: number
order: "desc"
parentID: null
cursor?: string
},
options: { signal?: AbortSignal },
) => Promise<SessionsResponse>,
signal?: AbortSignal,
@@ -19,6 +24,7 @@ export async function loadHomeSessionIndex(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
parentID: null,
...(cursor ? { cursor } : {}),
},
{ signal },
@@ -29,8 +35,7 @@ export async function loadHomeSessionIndex(
}
}
// The V2 API cannot yet filter roots, archives, or several directories, so Home
// seeds createData from a full scan and derives its visible index there.
// Keep this filter for locally known sessions merged into the fetched index.
export function parseHomeSessionIndex(sessions: SessionInfo[]) {
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
}
+4 -1
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@@ -61,6 +61,7 @@ type ServerSDKBase = {
scope: ServerScope
url: string
api: ServerApi
pty: ReturnType<typeof createPtyClient>
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
@@ -72,6 +73,7 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
@@ -93,6 +95,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
pty,
connection,
event: events.event,
}
@@ -0,0 +1,38 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/context/layout"
import { compareSessionTime, displayName } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
export type HomeSessionRecord = {
session: SessionInfo
project: LocalProject
projectName: string
}
export function buildHomeSessionRecords(input: {
sessions: () => SessionInfo[]
projectDirectories: () => string[] | undefined
projects: () => LocalProject[]
}) {
const selected = input.projectDirectories()
const directories = selected ? new Set(selected.map(pathKey)) : undefined
const sessions = directories
? input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
: input.sessions()
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const directory = pathKey(session.location.directory)
const project = input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
return { session, project, projectName: displayName(project) }
})
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/context/layout"
import { buildHomeSessionRecords } from "./home-session-records"
const session = (id: string, directory: string, projectID: string) =>
({
id,
projectID,
title: id,
location: { directory },
time: { created: 1, updated: 1 },
}) as SessionInfo
describe("buildHomeSessionRecords", () => {
const opened = { id: "project-a", worktree: "/repo/a", expanded: true } as LocalProject
const sessions = [session("a", "/repo/a", "project-a"), session("b", "/repo/b", "project-b")]
test("includes sessions outside added projects when unfiltered", () => {
const records = buildHomeSessionRecords({
sessions: () => sessions,
projectDirectories: () => undefined,
projects: () => [opened],
})
expect(records.map((record) => record.session.id)).toEqual(["a", "b"])
expect(records[1]?.project).toMatchObject({ id: "project-b", worktree: "/repo/b", expanded: false })
})
test("filters sessions when a project is selected", () => {
const records = buildHomeSessionRecords({
sessions: () => sessions,
projectDirectories: () => ["/repo/a"],
projects: () => [opened],
})
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
})
@@ -13,20 +13,19 @@ import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { errorMessage } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeController } from "./home-controller"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./home-session-records"
export type { HomeSessionRecord } from "./home-session-records"
const HOME_SESSION_LIMIT = 64
export type HomeSessionRecord = {
session: SessionInfo
project: LocalProject
projectName: string
}
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -41,13 +40,11 @@ export function createHomeSessionsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
const project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
return directories(project)
return project ? directories(project) : [selected]
})
const projectByID = createMemo(
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const sessionLoad = useQuery(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
@@ -61,13 +58,14 @@ export function createHomeSessionsController(home: HomeController) {
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
select: selectSessions,
}
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
if (!ctx) return []
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -77,7 +75,6 @@ export function createHomeSessionsController(home: HomeController) {
sessions: indexedSessions,
projectDirectories,
projects: home.project.list,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
@@ -154,7 +151,7 @@ export function createHomeSessionsController(home: HomeController) {
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, home.project.list(), projectByID())
)
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -202,30 +199,6 @@ function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function buildHomeSessionRecords(input: {
sessions: () => SessionInfo[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.flatMap((session) => {
const directory = pathKey(session.location.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return { session, project, projectName: displayName(project) }
})
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
@@ -266,6 +239,7 @@ export function HomeSessionStatusController(props: {
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
return props.render({
unread: avatar.unread,
@@ -1,33 +1,39 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/context/global"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/context/servers"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
const global = useGlobal()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
const data = serverCtx()?.data
if (!data) return []
if (!root?.()) return data.session.list()
const id = sessionId()
return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
const info = data.session.get(id)
return info ? [info] : []
})
})
const hasPermissions = createMemo(() => {
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(
ctx.data.session.list(),
ctx.data.session.permission.list,
sessionId(),
(item) => {
return !permission.autoResponds(item, directory())
},
)
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
@@ -4,10 +4,10 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useComments } from "@/context/comments"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { createPromptInputController, createPromptProjectControls } from "@/session/composer"
import { createPromptModelSelection } from "@/session/composer/prompt-model-selection"
import { useSessionKey } from "@/session/session-layout"
import { useComposerCommands } from "@/session/commands/use-composer-commands"
export function createNewSessionDraftController(props: {
worktree: () => string
@@ -17,7 +17,7 @@ import { StatusPopoverV2 } from "@/components/status-popover"
import { useLanguage } from "@/context/language"
import { useWorkspaceLocation } from "@/context/location"
import { useProviders } from "@/hooks/use-providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/new-session/new-session-layout"
import { Persist, persisted } from "@/utils/persist"
import type { NewSessionDraftController } from "./new-session-draft-controller"
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
File diff suppressed because it is too large Load Diff
@@ -1,322 +0,0 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Keybind } from "@opencode-ai/ui/keybind"
import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal } from "@/context/terminal"
import { useWorkspaceLocation } from "@/context/location"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
const command = useCommand()
const { workspaceKey, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
let root: HTMLDivElement | undefined
let tabList: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({
autoCreated: false,
recovered: {} as Record<string, boolean>,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
const stacked = createMemo(() => isDesktop() && props.stacked)
const panelHeight = createMemo(() =>
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
)
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
onMount(() => {
if (typeof window === "undefined") return
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
const port = window.visualViewport
sync()
makeEventListener(window, "resize", sync)
if (port) makeEventListener(port, "resize", sync)
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
if (!opened()) return
close()
},
),
)
createEffect(
on(
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
([next, id, requested]) => {
if (!next || !id || !requested) return
focusTerminalById(id)
},
),
)
createEffect(() => {
if (opened()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
createEffect(() => {
const dir = sdk().directory
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
workspaceKey(),
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = sdk().directory
if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? []
})
const all = terminal.all
const ids = createMemo(() => all().map((pty) => pty.id))
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
if (store.recovered[key]) return
setStore("recovered", key, true)
void clone(id)
}
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
return String(pty.titleNumber || pty.title || pty.id)
}
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
setStore("recovered", key, false)
trim(id)
}
const handleTerminalDragEnd = () => {
const activeId = terminal.active()
if (!activeId) return
requestAnimationFrame(() => {
if (terminal.active() !== activeId) return
focusTerminalById(activeId)
})
}
return (
<aside
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
"w-0 h-full pointer-events-none": isDesktop() && !opened(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop(),
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!isDesktop() && !size.active(),
}}
style={{ height: panelHeight() }}
>
<div classList={{ "md:hidden": !stacked(), hidden: stacked() }} onPointerDown={() => size.start()}>
<ResizeHandle
class="-top-1"
direction="vertical"
size={pane()}
min={100}
max={max()}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<div
class="absolute inset-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": opened() && !isDesktop(),
"pointer-events-none": !opened(),
}}
style={{ height: contentHeight() }}
>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
!!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
terminal.move(source.id.toString(), source.index)
}
handleTerminalDragEnd()
}}
>
<div class="flex flex-col h-full">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-[52px] !flex-none"
>
<Tabs.List
ref={tabList}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}>
{(pty, index) => <SortableTerminalTabV2 terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={opened() && terminal.active()} keyed>
{(id) => {
const ops = terminal.bind()
return (
<Show when={all().find((pty) => pty.id === id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
autoFocus={terminal.focusRequested(id)}
onAutoFocus={() => terminal.consumeFocus(id)}
class="!px-[14px]"
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
/>
</div>
)}
</Show>
)
}}
</Show>
</div>
</div>
</DragDropProvider>
</Show>
</div>
</aside>
)
}
@@ -3,8 +3,8 @@ import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -15,15 +15,15 @@ import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionController } from "./session-controller"
import type { SessionModel } from "@/session/model"
type SessionCommandSource = {
identity: SessionController["identity"]
data: Pick<SessionController["data"], "info" | "revertMessageID">
history: Pick<SessionController["history"], "userMessages" | "visibleUserMessages">
layout: SessionController["layout"]
ownership: SessionController["ownership"]
tabs: Pick<SessionController["tabs"], "activeFileTab" | "closableTab">
identity: SessionModel["identity"]
data: Pick<SessionModel["data"], "info" | "revertMessageID">
history: Pick<SessionModel["history"], "userMessages" | "visibleUserMessages">
layout: SessionModel["layout"]
ownership: SessionModel["ownership"]
tabs: Pick<SessionModel["tabs"], "activeFileTab" | "closableTab">
}
export type SessionCommandContext = {
@@ -63,7 +63,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionController["ownership"]["capture"]>
owner: ReturnType<SessionModel["ownership"]["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
@@ -116,82 +116,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copyShare = async (url: string, existing: boolean) => {
if (!(await write(url))) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const share = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const existing = undefined
if (existing) {
await copyShare(existing, true)
return
}
// TODO: Restore sharing when the V2 client exposes a session sharing API.
const url = undefined
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copyShare(url, false)
}
const unshare = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
})
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -373,36 +297,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
)
}
const shareCmds = () => {
// TODO: Restore these commands when the V2 client exposes session sharing.
// Sharing remains disabled until the current API exposes it.
return []
/*
return [
sessionCommand({
id: "session.share",
title: actions.session.data.info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: actions.session.data.info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !actions.session.identity.params.id,
onSelect: share,
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.share?.url,
onSelect: unshare,
}),
]
*/
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
@@ -594,7 +488,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
@@ -0,0 +1,416 @@
import type { SessionUserActions } from "@opencode-ai/session-ui/message"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { useMutation } from "@tanstack/solid-query"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, Show, untrack, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useComposerCommands } from "../commands/use-composer-commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { Persist, persisted } from "@/utils/persist"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import {
createPromptInputController,
createSessionComposerController,
createSessionComposerRegionController,
SessionComposerRegion,
} from "./index"
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
export function createActiveSessionComposer(input: {
session: SessionModel
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
deferRender: Accessor<boolean>
}) {
const comments = useComments()
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const location = useWorkspaceLocation()
const navigate = useNavigate()
const platform = usePlatform()
const prompt = usePrompt()
const server = useServerSDK()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const state = createSessionComposerController()
const controls = createPromptInputController({
sessionKey: input.session.identity.sessionKey,
sessionID: () => input.session.identity.params.id,
})
const [view, setView] = createStore({ newSessionWorktree: "main" })
const [followup, setFollowup] = persisted(
Persist.serverWorkspace(server.scope, location().directory, "followup"),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
paused: Record<string, boolean | undefined>
edit: Record<string, FollowupEdit | undefined>
}>({ items: {}, failed: {}, paused: {}, edit: {} }),
)
let promptRef: HTMLDivElement | undefined
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
if (input.session.identity.params.id) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
createEffect(
on(
() => [input.timeline.lastUserMessage(), input.session.data.info()] as const,
() => {
const message = input.timeline.lastUserMessage()
const info = input.session.data.info()
const metadata = message?.metadata
const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent
const model = metadata?.model
const selected =
model &&
typeof model === "object" &&
!Array.isArray(model) &&
typeof model.providerID === "string" &&
typeof model.modelID === "string"
? {
providerID: model.providerID,
modelID: model.modelID,
variant: typeof model.variant === "string" ? model.variant : undefined,
}
: info?.model
? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant }
: undefined
if (info && agent && selected) syncSessionModel(local, { sessionID: info.id, agent, model: selected })
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect(
on(
() => ({ directory: location().directory, id: input.session.identity.params.id }),
(next, previous) => {
if (!previous || (next.directory === previous.directory && next.id === previous.id)) return
if (previous.id && !next.id) local.session.reset()
},
{ defer: true },
),
)
createEffect(
on(
() => location().directory,
(directory) => {
if (directory) setView("newSessionWorktree", "main")
},
{ defer: true },
),
)
const newSessionWorktree = createMemo(() => {
if (view.newSessionWorktree === "create") return "create"
const project = input.session.project()
if (project && location().directory !== project.worktree) return location().directory
return "main"
})
const queuedFollowups = createMemo(() => {
const id = input.session.identity.params.id
return id ? (followup.items[id] ?? emptyFollowups) : emptyFollowups
})
const editingFollowup = createMemo<FollowupEdit | undefined>(() => {
const id = input.session.identity.params.id
return id ? followup.edit[id] : undefined
})
const followupMutation = useMutation(() => ({
mutationFn: async (request: { sessionID: string; id: string; manual?: boolean }) => {
if (!(followup.items[request.sessionID] ?? []).some((entry) => entry.id === request.id)) return
if (request.manual) setFollowup("paused", request.sessionID, undefined)
setFollowup("failed", request.sessionID, undefined)
// TODO: Restore queued followups once submission uses current admission APIs.
setFollowup("failed", request.sessionID, request.id)
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo<string | undefined>(() => {
const id = input.session.identity.params.id
return id && followupBusy(id) ? followupMutation.variables?.id : undefined
})
const followupText = (item: FollowupDraft) => {
const text = item.prompt
.map((part) => {
if (part.type === "image") return `[image:${part.filename}]`
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
return part.content
})
.join("")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => !!line)
return text || `[${language.t("common.attachment")}]`
}
const sendFollowup = (sessionID: string, id: string, manual?: boolean) => {
if (input.session.shared.data.session.get(sessionID)?.parentID) return Promise.resolve()
if (!(followup.items[sessionID] ?? []).some((entry) => entry.id === id) || followupBusy(sessionID)) {
return Promise.resolve()
}
return followupMutation.mutateAsync({ sessionID, id, manual })
}
const editFollowup = (id: string) => {
const sessionID = input.session.identity.params.id
if (!sessionID || followupBusy(sessionID)) return
const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
setFollowup("edit", sessionID, { id: item.id, prompt: item.prompt, context: item.context })
}
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
const download = () => {
const anchor = document.createElement("a")
anchor.href = url
anchor.download = getFilename(file.name) || "attachment"
anchor.click()
}
const path = file.name ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (!platform.revealPath || !absolute) return download()
void platform.revealPath(path).then((revealed) => {
if (!revealed) download()
}, download)
}
createEffect(() => {
const sessionID = input.session.identity.params.id
const item = queuedFollowups()[0]
if (!sessionID || !item || followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id || followup.paused[sessionID]) return
if (input.session.data.isChild() || state.blocked() || input.session.data.working()) return
void sendFollowup(sessionID, item.id)
})
const focus = () => {
if (!input.session.data.isChild()) promptRef?.focus()
}
createEffect(
on(
() => input.session.identity.params.id,
(id) => {
if (!id) requestAnimationFrame(focus)
},
),
)
const editable = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const activeElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement)
current = current.shadowRoot.activeElement
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const active = activeElement()
if (
path.some((item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null) ||
editable(target) ||
(active && (active.closest("[data-prevent-autofocus]") || editable(active))) ||
dialog.active
)
return
if (active === promptRef) {
if (event.key === "Escape") promptRef?.blur()
return
}
const key = scrollKey(event)
if (key) {
const scroller = input.timeline.scroller()
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
input.timeline.view.markGesture(scroller)
return
}
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
if (state.blocked() || input.session.data.isChild() || !promptRef) return
promptRef.focus()
setCursorPosition(promptRef, prompt.cursor() ?? promptLength(prompt.current()))
}
onMount(() => makeEventListener(document, "keydown", handleKeyDown))
useComposerCommands()
useSessionCommands({
session: input.session,
background: {
blocking: () => state.background.blocking().length > 0,
move: state.background.move,
},
navigateMessageByOffset: input.timeline.actions.navigateMessage,
setActiveMessage: input.timeline.actions.setActiveMessage,
focusInput: focus,
})
command.register("session-palette", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: () => command.trigger("file.open", "palette"),
},
])
return {
actions: {
focus,
timeline: { revert: () => undefined, openAttachment } satisfies SessionUserActions,
},
blocked: state.blocked,
background: state.background,
newSessionWorktree,
queue: {
edit: editingFollowup,
enabled: () => false,
enqueue: (draft: FollowupDraft) => {
setFollowup("items", draft.sessionID, (items) => [
...(items ?? []),
{ id: SessionMessage.ID.create(), ...draft },
])
setFollowup("failed", draft.sessionID, undefined)
setFollowup("paused", draft.sessionID, undefined)
},
onEditLoaded: () => {
const id = input.session.identity.params.id
if (id) setFollowup("edit", id, undefined)
},
pause: () => {
const id = input.session.identity.params.id
if (id) setFollowup("paused", id, true)
},
},
region: {
centered: input.screen.centered,
followup: () =>
input.session.identity.params.id && !input.session.data.isChild()
? {
items: queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })),
sending: sendingFollowup(),
onSend: (id: string) => void sendFollowup(input.session.identity.params.id!, id, true),
onEdit: editFollowup,
}
: undefined,
openParent: () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
},
prompt,
ready: () => !input.deferRender() && input.timeline.ready(),
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
state,
},
input: {
controls,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
},
resetWorktree: () => setView("newSessionWorktree", "main"),
submit: () => {
comments.clear()
input.timeline.actions.resume()
},
workspaceMoveEligible: createMemo(() => {
const id = input.session.identity.params.id
if (!id) return false
return (
(followup.items[id]?.length ?? 0) === 0 && !followup.failed[id] && !followup.paused[id] && !followup.edit[id]
)
}),
}
}
export type ActiveSessionComposerModel = ReturnType<typeof createActiveSessionComposer>
export function ActiveSessionComposer(props: {
model: ActiveSessionComposerModel
session: SessionModel
accentSubmit: boolean
onResponseSubmit: () => void
}) {
const region = createSessionComposerRegionController({
state: props.model.region.state,
sessionKey: props.session.identity.sessionKey,
sessionID: () => props.session.identity.params.id,
prompt: props.model.region.prompt,
ready: props.model.region.ready,
centered: props.model.region.centered,
followup: props.model.region.followup,
revert: () => undefined,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
setPromptRef: props.model.region.setPromptRef,
setDockRef: props.model.region.setDockRef,
})
const promptInput = usePromptInputV2Controller({
get controls() {
return props.model.input.controls()
},
ref: props.model.input.setPromptRef,
get newSessionWorktree() {
return props.model.newSessionWorktree()
},
onNewSessionWorktreeReset: props.model.resetWorktree,
onSubmit: props.model.submit,
get edit() {
return props.model.queue.edit()
},
onEditLoaded: props.model.queue.onEditLoaded,
shouldQueue: props.model.queue.enabled,
onQueue: props.model.queue.enqueue,
onAbort: props.model.queue.pause,
})
return (
<Show when={props.session.identity.params.id}>
<SessionComposerRegion
controller={region}
promptInput={
<PromptInputV2Composer controller={promptInput} borderUnderlay accentSubmit={props.accentSubmit} />
}
/>
</Show>
)
}
@@ -1,4 +1,3 @@
import { useNavigate, useSearchParams } from "@solidjs/router"
import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input/contracts"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
@@ -1,7 +1,7 @@
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
import type { PromptInputState } from "@/components/prompt-input"
import { useData } from "@/context/server"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { getSessionHandoff, setSessionHandoff } from "@/session/handoff"
import type { SessionComposerController } from "./session-composer-state"
export type SessionComposerFollowupDock = {
@@ -1,10 +1,10 @@
import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionFollowupDock } from "@/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/session/composer/session-revert-dock"
import { SessionBackgroundDock } from "@/session/requests/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -7,11 +7,9 @@ import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useWorkspaceLocation } from "@/context/location"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { useData } from "@/context/server"
const idle = { type: "idle" as const }
export function createSessionComposerController() {
const params = useParams()
const sdk = useWorkspaceLocation()
@@ -1,7 +1,7 @@
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { DockShell } from "@opencode-ai/ui/dock-surface"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionRevertDock } from "@/session/composer/session-revert-dock"
import { SettingsProvider } from "@/context/settings"
export default {
@@ -15,7 +15,7 @@ export default {
Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
### Source path
- \`packages/app/src/pages/session/composer/session-revert-dock.tsx\`
- \`packages/app/src/session/composer/session-revert-dock.tsx\`
### Why the mock composer
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -16,9 +16,9 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { getSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
type SessionFileViewProps = {
tab: string
@@ -452,7 +452,7 @@ export function SessionFileView(props: SessionFileViewProps) {
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
@@ -3,7 +3,7 @@ import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
@@ -38,7 +38,7 @@ export function applyFileListKeyDown(
// row data-slots on purpose so file-tree-v2.css styles both. data-highlighted has
// no CSS of its own — it folds into data-selected below and only exists as the
// scrollIntoView query hook.
export function SessionFileListV2(props: {
export function SessionFileList(props: {
files: readonly string[]
active?: string
highlighted?: string
@@ -6,9 +6,9 @@ import { Menu } from "@opencode-ai/ui/menu"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "@opencode-ai/ui/split-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppV2(props: { directory: () => string }) {
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
@@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionContextFormatter } from "./session-context-format"
function Stat(props: { label: string; value: JSX.Element }) {
@@ -6,13 +6,12 @@ import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-revie
import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { displayName } from "@/pages/layout/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileView } from "@/session/files/file-tabs"
import { applyFileListKeyDown, SessionFileList } from "@/session/files/list"
import { pathKey } from "@/utils/path-key"
import { useServer } from "@/context/server"
@@ -38,7 +37,6 @@ export function SessionFileBrowserTab(props: {
}) {
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const sdk = useWorkspaceLocation()
const server = useServer()
const serverSDK = useServerSDK()
@@ -143,7 +141,7 @@ export function SessionFileBrowserTab(props: {
</div>
}
>
<SessionFileListV2
<SessionFileList
id={resultsID}
role="listbox"
optionID={optionID}
@@ -1,6 +1,6 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
@@ -16,30 +16,31 @@ import type { FileDiffInfo } from "@opencode-ai/client/promise"
import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTabV2 } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useWorkspaceLocation } from "@/context/location"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
} from "@/session/helpers"
import { setSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
@@ -261,7 +262,7 @@ export function SessionSidePanel(props: {
<Show when={reviewOpen()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
<DndKitProvider
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
@@ -345,7 +346,7 @@ export function SessionSidePanel(props: {
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTabV2
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
@@ -414,7 +415,7 @@ export function SessionSidePanel(props: {
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppV2 directory={projectDirectory} />
<OpenInAppButton directory={projectDirectory} />
</div>
</div>
@@ -476,7 +477,7 @@ export function SessionSidePanel(props: {
</div>
</Show>
</Tabs>
</DndKitProvider>
</DragDropProvider>
</div>
</div>
</Show>
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
export function SortableTab(props: {
tab: string
index: number
temporary?: boolean
@@ -4,7 +4,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export type SessionHeaderV2ActionsState = {
export type SessionHeaderActionsState = {
status?: { label: string; content: () => JSX.Element }
reviewLabel: string
reviewKeybind: string[]
@@ -13,7 +13,7 @@ export type SessionHeaderV2ActionsState = {
onReviewToggle: () => void
}
export function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.status}>
@@ -4,11 +4,11 @@ import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
import { SessionHeaderV2Actions, type SessionHeaderV2ActionsState } from "./session-header-actions"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/components/command-tooltip-keybind"
import { StatusPopoverV2 } from "@/components/status-popover"
import { useTitlebarRightMount } from "@/components/titlebar"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
const command = useCommand()
@@ -19,7 +19,7 @@ export function SessionHeader() {
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
const actions = createMemo<SessionHeaderActionsState>(() => ({
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopoverV2 /> } : undefined,
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
@@ -34,7 +34,7 @@ export function SessionHeader() {
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderV2Actions state={v2ActionsState()} />
<SessionHeaderActions state={actions()} />
</Portal>
)}
</Show>
@@ -119,7 +119,7 @@ export const createOpenReviewFile = (input: {
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
loadFile: (path: string) => unknown
}) => {
return (path: string) => {
batch(() => {
@@ -1,8 +1,11 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createMemo, type Accessor } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createMemo } from "solid-js"
import { useFile } from "@/context/file"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { same } from "@/utils/same"
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -17,14 +20,12 @@ const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
export function useSessionModel() {
const file = useFile()
const data = useData()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => {
const id = sessionID()
@@ -50,17 +51,34 @@ export function createSessionController(input: {
emptyUserMessages,
{ equals: same },
)
const project = createMemo(() => {
const current = info()
const value = current?.projectID
? data.project.get(current.projectID)
: data.project.list().find((item) => containsDirectory(item.canonical, location().directory))
if (!value) return
return { ...value, worktree: value.canonical, worktrees: [] }
})
const canReview = createMemo(() => !!project())
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
review: isDesktop,
hasReview: canReview,
fileBrowser: () => isDesktop() && !!sessionID(),
})
return {
shared: { data },
project,
canReview,
isDesktop,
workspace: {
directory: createMemo(() => info()?.location.directory ?? location().directory),
current: createMemo(() => isWorkspaceDirectory(project(), info()?.location.directory ?? location().directory)),
},
identity: {
params: layout.params,
sessionID,
@@ -98,4 +116,4 @@ export function createSessionController(input: {
}
}
export type SessionController = ReturnType<typeof createSessionController>
export type SessionModel = ReturnType<typeof useSessionModel>
@@ -3,7 +3,7 @@ import { useCommand } from "@/context/command"
import { Keybind } from "@opencode-ai/ui/keybind"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionComposerPullout } from "./session-composer-pullout"
import { SessionComposerPullout } from "@/session/composer/session-composer-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
+427
View File
@@ -0,0 +1,427 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode-ai/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useComments } from "@/context/comments"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { createOpenReviewFile } from "../helpers"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { createReviewPanelState } from "./panel-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
import type { DiffStyle } from "./review-tab"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
export function createSessionReview(input: {
session: SessionModel
screen: SessionScreenLayout
deferRender: Accessor<boolean>
}) {
const data = input.session.shared.data
const queryClient = useQueryClient()
const comments = useComments()
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const prompt = usePrompt()
const location = useWorkspaceLocation()
const server = useServerSDK()
const [state, setState] = createStore({
mobileTab: "session" as "session" | "changes",
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
})
const mode = () => input.session.layout.view().review.mode() ?? "git"
const selectedFile = () => input.session.layout.view().review.file()
createEffect(() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path)
})
const vcs = createMemo(() => data.location.vcs.info({ directory: location().directory }))
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs === "git") list.push("git")
if (
project?.vcs === "git" &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
) {
list.push("branch")
}
return list
})
const mobileChanges = createMemo(() => !input.session.isDesktop() && state.mobileTab === "changes")
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
server.scope,
"session-vcs",
location().directory,
vcs()?.branch.current ?? "",
vcs()?.branch.default ?? "",
] as const,
)
const wantsReview = createMemo(() =>
input.session.isDesktop()
? input.screen.files.open() ||
(input.screen.review.open() &&
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
: mobileChanges(),
)
const vcsQuery = createQuery(() => {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
server.api.vcs
.diff({
location: { directory: location().directory },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
}
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const refresh = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
void queryClient.invalidateQueries({
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
})
}, 100)
onCleanup(
location().event.listen((event) => {
if (event.type === "filesystem.changed") refresh()
}),
)
createEffect(
on(
() => input.screen.review.open() || mobileChanges(),
(open, previous) => {
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
refresh()
},
{ defer: true },
),
)
const diffs = () => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const activeFile = () => {
const list = diffs()
const selected = selectedFile()
if (selected && list.some((diff) => diff.file === selected)) return selected
return list[0]?.file
}
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
const directory = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return undefined
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [server.scope, ...vcsKey(), value, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
server.api.vcs
.diff({
location: { directory: scope },
mode: value === "git" ? "working" : value,
context,
})
.then((result) => result.data),
})
.then((result) => result.find((diff) => diff.file === path))
if (directory !== root) {
try {
const scoped = valid(await request(directory))
if (scoped) return scoped
} catch (error) {
console.debug("[session-review] failed to load scoped vcs diff", { mode: value, path, directory, error })
}
}
try {
const bounded = valid(await request(root, 3))
if (bounded) return bounded
} catch (error) {
console.debug("[session-review] failed to load bounded vcs diff", { mode: value, path, root, error })
}
return undefined
}
const selectionPreview = (path: string, selection: FileSelection): string | undefined => {
const content = file.get(path)?.content?.content
if (!content) return undefined
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
}
const addComment = (comment: SessionReviewLineComment) => {
const selection = selectionFromLines(comment.selection)
const saved = comments.add({ file: comment.file, selection: comment.selection, comment: comment.comment })
prompt.context.add({
type: "file",
path: comment.file,
selection,
comment: comment.comment,
commentID: saved.id,
commentOrigin: "review",
preview: comment.preview ?? selectionPreview(comment.file, selection),
})
}
const updateComment = (comment: {
id: string
file: string
selection: SelectedLineRange
comment: string
preview?: string
}) => {
comments.update(comment.file, comment.id, comment.comment)
prompt.context.updateComment(comment.file, comment.id, {
comment: comment.comment,
...(comment.preview ? { preview: comment.preview } : {}),
})
}
const removeComment = (comment: { id: string; file: string }) => {
comments.remove(comment.file, comment.id)
prompt.context.removeComment(comment.file, comment.id)
}
const commentActions = createMemo(() => ({
moreLabel: language.t("common.moreOptions"),
editLabel: language.t("common.edit"),
deleteLabel: language.t("common.delete"),
saveLabel: language.t("common.save"),
}))
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const open = () => {
if (!input.session.layout.view().reviewPanel.opened()) input.session.layout.view().reviewPanel.open()
}
const openFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
openTab: (tab) => input.session.layout.tabs().open(tab),
setActive: (tab) => input.session.layout.tabs().setActive(tab),
loadFile: file.load,
})
const reviewDiffId = (path: string): string | undefined => {
const sum = checksum(path)
if (!sum) return undefined
return `session-review-diff-${sum}`
}
const reviewDiffTop = (path: string): number | undefined => {
if (!state.scroll) return undefined
const id = reviewDiffId(path)
if (!id) return undefined
const element = document.getElementById(id)
if (!(element instanceof HTMLElement) || !state.scroll.contains(element)) return undefined
const target = element.getBoundingClientRect()
const root = state.scroll.getBoundingClientRect()
return target.top - root.top + state.scroll.scrollTop
}
const scrollToFile = (path: string) => {
if (!state.scroll) return false
const top = reviewDiffTop(path)
if (top === undefined) return false
input.session.layout.view().setScroll("review", { x: state.scroll.scrollLeft, y: top })
state.scroll.scrollTo({ top, behavior: "auto" })
return true
}
const focusFile = (path: string) => {
open()
input.session.layout.view().review.openPath(path)
input.session.layout.view().review.setFile(path)
setState("pendingFile", path)
}
createEffect(() => {
const pending = state.pendingFile
if (!pending || !state.scroll || !ready()) return
const attempt = (count: number) => {
if (state.pendingFile !== pending) return
if (count > 60) {
setState("pendingFile", undefined)
return
}
if (!state.scroll || !scrollToFile(pending)) {
requestAnimationFrame(() => attempt(count + 1))
return
}
const top = reviewDiffTop(pending)
if (top === undefined || Math.abs(state.scroll.scrollTop - top) > 1) {
requestAnimationFrame(() => attempt(count + 1))
return
}
setState("pendingFile", undefined)
}
requestAnimationFrame(() => attempt(0))
})
createEffect(
on(
input.session.tabs.activeFileTab,
(active) => {
if (!active || layout.fileTree.tab() !== "changes") return
showAllFiles()
},
{ defer: true },
),
)
let treeDirectory: string | undefined
createEffect(() => {
const directory = location().directory
if (!input.session.isDesktop() || !layout.fileTree.opened() || server.connection.status() !== "connected") return
layout.fileTree.tab()
const refreshTree = treeDirectory !== directory
treeDirectory = directory
void (refreshTree ? file.tree.refresh("") : file.tree.list(""))
})
createEffect(
on(
() => location().directory,
() => {
const tab = input.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) void file.load(path, { force: true })
},
{ defer: true },
),
)
createEffect(() => {
if (!layout.ready() || server.connection.status() !== "connected" || !input.session.project()) return
const list = options()
const value = mode()
if (list.includes(value)) return
const next = list[0]
if (next) input.session.layout.view().review.setMode(next)
})
createEffect(
on(
() => data.session.status(input.session.identity.params.id ?? ""),
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
},
{ defer: true },
),
)
createEffect(
on(
input.session.identity.sessionKey,
() => {
setState("mobileTab", "session")
setState("scroll", undefined)
setState("pendingFile", undefined)
},
{ defer: true },
),
)
const panelState = createReviewPanelState()
const panelRendered = createMemo<boolean>((previous) => previous || !input.deferRender(), false)
return {
activeFile,
canReview: input.session.canReview,
comments: {
actions: commentActions,
add: addComment,
all: comments.all,
focus: comments.focus,
mentions: file.searchFilesAndDirectories,
remove: removeComment,
changeFocus: (focus: { file: string; id: string } | null) => {
if (!focus) {
const current = comments.focus()
if (current && diffs().some((diff) => diff.file === current.file)) focusFile(current.file)
}
comments.setFocus(focus)
},
setFocus: comments.setFocus,
update: updateComment,
},
count,
deferRender: input.deferRender,
details: {
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
setOpen: (open: boolean) => setState("detailsOpen", open),
},
diffVersion: () => vcsQuery.dataUpdatedAt,
diffStyle: {
current: layout.review.diffStyle,
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
},
diffs,
focusFile,
hasChanges,
loadDiff,
mobile: {
changes: mobileChanges,
setTab: (tab: "session" | "changes") => setState("mobileTab", tab),
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
open,
openFile,
options,
panelState,
panelRendered,
ready,
screen: input.screen,
setMode: (value: ChangeMode) => input.session.layout.view().review.setMode(value),
setScroll: (element: HTMLDivElement | undefined) => setState("scroll", element),
view: input.session.layout.view,
}
}
export type SessionReviewModel = ReturnType<typeof createSessionReview>
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import type { Platform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
export function createReviewPanelV2State(platform?: Platform) {
export function createReviewPanelState(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
@@ -40,4 +40,4 @@ export function createReviewPanelV2State(platform?: Platform) {
}
}
export type ReviewPanelV2State = ReturnType<typeof createReviewPanelV2State>
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -27,13 +27,13 @@ import {
reviewDiffKinds,
reviewDiffNeedsLoad,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
} from "@/session/review/review-diff-kinds"
import type { ReviewPanelState } from "@/session/review/panel-state"
import { applyFileListKeyDown, SessionFileList } from "@/session/files/list"
type ReviewDiff = FileDiffInfo
export type ReviewPanelV2Props = {
export type ReviewPanelProps = {
title?: JSX.Element
empty?: JSX.Element
diffs: ReviewDiff[]
@@ -44,7 +44,7 @@ export type ReviewPanelV2Props = {
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
state: ReviewPanelV2State
state: ReviewPanelState
onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
@@ -55,7 +55,7 @@ export type ReviewPanelV2Props = {
fileList?: "tree" | "flat"
}
export function ReviewPanelV2(props: ReviewPanelV2Props) {
export function ReviewPanel(props: ReviewPanelProps) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const readFile = async (path: string) =>
@@ -67,11 +67,11 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
return undefined
})
return <ReviewPanelV2View {...props} readFile={readFile} />
return <ReviewPanelView {...props} readFile={readFile} />
}
export function ReviewPanelV2View(
props: ReviewPanelV2Props & {
export function ReviewPanelView(
props: ReviewPanelProps & {
readFile?: (path: string) => Promise<{ type: "text"; content: string } | undefined>
},
) {
@@ -127,7 +127,7 @@ export function ReviewPanelV2View(
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
<ReviewPanelV2Sidebar
<ReviewPanelSidebar
title={props.title}
state={props.state}
diffsReady={props.diffsReady}
@@ -178,9 +178,9 @@ export function ReviewPanelV2View(
)
}
function ReviewPanelV2Sidebar(props: {
function ReviewPanelSidebar(props: {
title?: JSX.Element
state: ReviewPanelV2State
state: ReviewPanelState
diffsReady: boolean
onSelectFile: (path: string) => void
diffs: RenderDiff[]
@@ -247,7 +247,7 @@ function ReviewPanelV2Sidebar(props: {
/>
}
>
<SessionFileListV2
<SessionFileList
files={props.filteredFiles}
kinds={props.kinds}
active={props.activeDiff}
@@ -260,7 +260,7 @@ function ReviewPanelV2Sidebar(props: {
when={props.filteredFiles.length > 0}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
>
<SessionFileListV2
<SessionFileList
files={props.filteredFiles}
kinds={props.kinds}
active={props.activeDiff}
+224
View File
@@ -0,0 +1,224 @@
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Match, Show, Suspense, Switch } from "solid-js"
import { useLanguage } from "@/context/language"
import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
export function SessionMobileTabs(props: { review: SessionReviewModel; compact?: boolean; bottom?: boolean }) {
const language = useLanguage()
return (
<Tabs value={props.review.mobile.tab()} class="h-auto">
<Tabs.List
classList={{
"!h-9": props.compact,
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": props.bottom,
}}
>
<Tabs.Trigger
value="session"
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
props.bottom,
}}
onClick={() => props.review.mobile.setTab("session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none !border-r-0": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
props.bottom,
}}
onClick={() => props.review.mobile.setTab("changes")}
>
{props.review.hasChanges()
? language.t("session.review.filesChanged", { count: props.review.count() })
: language.plural("session.review.change", 0)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
)
}
export function SessionMobileReview(props: { review: SessionReviewModel }) {
return (
<div class="relative h-full overflow-hidden">
<ReviewContent review={props.review} />
</div>
)
}
export function SessionDesktopReview(props: { review: SessionReviewModel }) {
return (
<Suspense>
<SessionSidePanel
canReview={props.review.canReview()}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
hasReview={props.review.hasChanges()}
reviewHasFocusableContent={props.review.hasChanges() || props.review.panelState.sidebarOpened()}
reviewCount={props.review.count()}
reviewPanel={() => <ReviewPanelContent review={props.review} />}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={props.review.panelState.sidebarOpened()}
disabled={disabled}
onToggle={props.review.panelState.toggleSidebar}
/>
)}
fileBrowserState={props.review.panelState}
activeDiff={props.review.activeFile()}
focusReviewDiff={props.review.focusFile}
reviewSnap={props.review.screen.review.snap()}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
/>
</Suspense>
)
}
function ReviewContent(props: { review: SessionReviewModel }) {
return (
<Show when={!props.review.deferRender()}>
<SessionReviewTab
title={<ReviewTitle review={props.review} />}
empty={<ReviewEmpty review={props.review} loadingClass="px-4 py-4 text-text-weak" />}
diffs={props.review.diffs()}
view={props.review.view()}
diffStyle="unified"
onScrollRef={props.review.setScroll}
focusedFile={props.review.activeFile()}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
commentMentions={{ items: props.review.comments.mentions }}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.setFocus}
onViewFile={props.review.openFile}
classes={{
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
}}
/>
</Show>
)
}
function ReviewPanelContent(props: { review: SessionReviewModel }) {
return (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={props.review.panelRendered()}>
<ReviewPanel
title={<ReviewTitle review={props.review} />}
empty={<ReviewPanelEmpty review={props.review} />}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
diffVersion={props.review.diffVersion()}
loadDiff={props.review.loadDiff}
activeFile={props.review.activeFile()}
onSelectFile={props.review.focusFile}
diffStyle={props.review.diffStyle.current()}
onDiffStyleChange={props.review.diffStyle.set}
state={props.review.panelState}
onLineComment={props.review.comments.add}
onLineCommentUpdate={props.review.comments.update}
onLineCommentDelete={props.review.comments.remove}
lineCommentActions={props.review.comments.actions()}
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.changeFocus}
/>
</Show>
</div>
)
}
function ReviewTitle(props: { review: SessionReviewModel }) {
const language = useLanguage()
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
</Show>
)
}
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
}
return (
<Switch>
<Match when={loading()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</div>
</Match>
<Match when={true}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text()}</div>
</div>
</Match>
</Switch>
)
}
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
</Match>
</Switch>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Button } from "@opencode-ai/ui/button"
import { createMemo, Show } from "solid-js"
import { ErrorPage } from "@/pages/error"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName, useServers } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
export function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
const server = useServers()
const tabs = useTabs()
const displayServer = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === props.serverKey)
return conn ? serverName(conn) : props.serverKey
})
if (!isCurrentSessionNotFoundError(props.error, props.sessionID)) return <ErrorPage error={props.error} />
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
<div class="text-13-regular text-text-weak max-w-md">{language.t("session.error.notFound.description")}</div>
</div>
<Show when={props.sessionID}>
{(sessionID) => (
<div class="max-w-full flex flex-col items-center gap-1">
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
{sessionID()}
</code>
</div>
)}
</Show>
<Button
variant="neutral"
size="normal"
icon="xmark-small"
onClick={() => {
if (!props.sessionID || !props.serverKey) return
tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID })
}}
>
{language.t("session.error.notFound.closeTab")}
</Button>
</div>
</div>
)
}
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
if (!sessionID) return false
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
}
+122
View File
@@ -0,0 +1,122 @@
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { LocationProvider, useWorkspaceLocation } from "@/context/location"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PromptProvider } from "@/context/prompt"
import { useData, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection } from "@/context/servers"
import { TerminalProvider } from "@/context/terminal"
import { useSettingsCommand } from "@/components/settings-dialog"
import { SessionUIProvider } from "@/pages/directory-layout"
import { requireServerKey } from "@/utils/session-route"
import { useSessionModel } from "./model"
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreen } from "./screen"
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const data = useData()
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
return (
<>
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</ModelsProvider>
</>
)
}
function TargetSessionSettingsCommand() {
useSettingsCommand()
return null
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
return (
<ErrorBoundary
fallback={(error) => (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
)}
>
{props.children}
</ErrorBoundary>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ id: string }>()
const server = useServer()
const data = useData()
const current = createSessionResolution(
() => params.id,
() => data.session,
{ children: true },
)
const directory = createMemo(() => current()?.location.directory)
return (
<Show when={directory()}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
)}
</Show>
)
}
function TargetSessionPage() {
const location = useWorkspaceLocation()
const server = useServerSDK()
return (
// Keep workspace-scoped file, prompt, comment, and terminal state alive when
// the user switches between Sessions in the same workspace.
<Show when={`${server.scope}\0${location().directory}`} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>
<SessionPage />
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)
}
function SessionPage() {
const session = useSessionModel()
return <SessionScreen session={session} />
}
function MarkSessionNotificationsViewed(props: { sessionID: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
+110
View File
@@ -0,0 +1,110 @@
import { createComputed, createMemo, createSignal, onCleanup } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { createSizing, shouldShowFileTree } from "./helpers"
import type { SessionModel } from "./model"
import { sessionPanelLayout } from "./session-panel-layout"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
export function createSessionScreenLayout(session: SessionModel, serverScope: string) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
const fileTreeOpen = createMemo(
() =>
session.isDesktop() &&
shouldShowFileTree({
visible: settings.visibility.fileTree(),
opened: layout.fileTree.opened(),
}),
)
const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen())
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
const [rowWidth, setRowWidth] = createSignal<number>()
let row: HTMLDivElement | undefined
createResizeObserver(
() => row,
({ width }) => setRowWidth(width),
)
const available = createMemo<number | undefined>(() => {
const width = rowWidth()
if (width === undefined) return undefined
return width - 8
})
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
available: available(),
split: splitReview(),
}),
)
const panelWidth = createMemo(() => {
if (!sidePanelOpen()) return "100%"
if (resizable()) return `${resizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const panelMax = createMemo(() => {
const width = available()
if (width === undefined) return 1000
return sessionPanelWidthMax({ available: width, split: splitReview() })
})
const panelLayout = createMemo(() =>
sessionPanelLayout({
review: reviewPanelOpen(),
terminal: desktopTerminalOpen(),
files: fileTreeOpen(),
}),
)
const [reviewSnap, setReviewSnap] = createSignal(false)
let reviewFrame: number | undefined
createComputed((previous) => {
const open = reviewOpen()
if (previous === undefined || previous === open) return open
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
setReviewSnap(true)
reviewFrame = requestAnimationFrame(() => {
reviewFrame = undefined
setReviewSnap(false)
})
return open
}, reviewOpen())
onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
})
return {
centered: createMemo(() => session.isDesktop()),
files: { open: fileTreeOpen },
panel: {
key: createMemo(() => (session.identity.params.id ? `${serverScope}\0${session.identity.params.id}` : undefined)),
max: panelMax,
ref: (element: HTMLDivElement) => {
row = element
},
resizable,
resizedWidth,
width: panelWidth,
},
review: {
open: reviewOpen,
panelOpen: reviewPanelOpen,
snap: reviewSnap,
},
side: { layout: panelLayout, open: sidePanelOpen },
size,
terminal: {
desktopOpen: desktopTerminalOpen,
inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()),
open: terminalOpen,
},
}
}
export type SessionScreenLayout = ReturnType<typeof createSessionScreenLayout>
+202
View File
@@ -0,0 +1,202 @@
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { NewSessionView } from "@/session/composer/session-new-view"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/context/layout"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { TerminalPanel } from "@/session/terminal/panel"
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
import { createSessionScreenLayout } from "./screen-layout"
import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposer, createActiveSessionComposer } from "./composer/active-session"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const serverSDK = useServerSDK()
const settings = useSettings()
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session, serverSDK.scope)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({ deferRender: false })
createComputed((prev) => {
const key = session.identity.sessionKey()
if (key !== prev) {
setStore("deferRender", true)
const owner = session.ownership.capture()
requestAnimationFrame(() => {
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
})
}
return key
})
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
const composer = createActiveSessionComposer({
session,
screen,
timeline,
deferRender: () => store.deferRender,
})
useUsageExceededDialogs()
const mobileTabsBottom = createMemo(() => !isDesktop() && settings.general.mobileTitlebarPosition() === "bottom")
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={session.identity.params.id} />
}
const sessionPanelContent = () => (
<>
{timeline.resource() ?? ""}
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
<SessionMobileTabs review={review} compact />
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
</Match>
<Match when={session.identity.params.id}>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={session}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onAutoScrollHandleScroll={timeline.autoScroll.handleScroll}
onMarkScrollGesture={timeline.view.markGesture}
hasScrollGesture={timeline.view.hasGesture()}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onAutoScrollInteraction={timeline.autoScroll.handleInteraction}
shouldAnchorBottom={timeline.view.shouldAnchorBottom()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
userMessages={timeline.userMessages()}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
setHistoryAnchor={timeline.view.setHistoryAnchor}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
/>
)}
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={composer.newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={session.identity.params.id && !review.mobile.changes()}>
<ActiveSessionComposer
model={composer}
session={session}
accentSubmit={session.workspace.current()}
onResponseSubmit={timeline.actions.resume}
/>
</Show>
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
<SessionMobileTabs review={review} compact bottom />
</Show>
</>
)
return (
<SessionRouteFrame>
<SessionHeader />
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2 p-2">
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
}}
style={{
width: screen.panel.width(),
}}
>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
<Show when={screen.panel.resizable()}>
<div onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="-end-1"
direction="horizontal"
size={screen.panel.resizedWidth()}
min={SESSION_PANEL_WIDTH_MIN}
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={isDesktop() ? screen.side.layout().visible : screen.terminal.open()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop() && (screen.review.panelOpen() || screen.files.open())}>
<div class="min-h-0 flex-1">
<SessionDesktopReview review={review} />
</div>
</Show>
<Show when={screen.side.layout().stacked}>
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<Show when={screen.terminal.open()}>
<div
classList={{
"min-h-0 shrink-0": screen.side.layout().stacked,
"min-h-0 flex-1": !screen.side.layout().stacked,
}}
>
<TerminalPanel stacked={screen.side.layout().stacked} />
</div>
</Show>
</div>
</Show>
</div>
</SessionRouteFrame>
)
}
@@ -2,6 +2,7 @@ import {
activePermissionRequest,
activeQuestionRequest,
attachmentsAndCommentsDocument,
compactionDocument,
editThenTestDocument,
emptySessionDocument,
largeCompletedDocument,
@@ -9,12 +10,32 @@ import {
permissionPendingDocument,
questionPendingDocument,
queuedPrompts,
recoveryDocument,
thinkingDocument,
retryDocument,
streamingDocument,
STORY_TIME,
subagentDocument,
terminalPassedDocument,
} from "@opencode-ai/session-ui/storybook"
import { SessionPreview } from "./session-preview"
import { SessionPreview } from "./story-model"
const description = "opencode · modular-session-ui"
const retryAfterInterruption = {
...retryDocument,
messages: [
...compactionDocument.messages,
...retryDocument.messages.map((message, index) => {
const created = STORY_TIME + 70_000 + index * 1_000
if (message.type !== "assistant" || !message.retry) {
return { ...message, time: { ...message.time, created } }
}
return {
...message,
time: { ...message.time, created },
retry: { ...message.retry, at: created },
}
}),
],
}
const implementAndVerify = () => (
<SessionPreview
title="Update active Session status"
@@ -39,23 +60,6 @@ export default {
},
}
export const StartACodingTask = {
render: () => (
<SessionPreview
title="New Session"
description={description}
document={emptySessionDocument}
draft="Find why the Session header shifts after the first streamed response"
/>
),
}
export const AgentIsThinking = {
render: () => (
<SessionPreview title="Fix Session header shift" description={description} document={thinkingDocument} />
),
}
export const ImplementAndVerifyLight = {
globals: { theme: "light" },
render: implementAndVerify,
@@ -78,50 +82,6 @@ export const QueueAFollowUp = {
),
}
export const PermissionRequired = {
render: () => (
<SessionPreview
title="Publish canary preview"
description={description}
document={permissionPendingDocument}
request={{ type: "permission", value: activePermissionRequest }}
/>
),
}
export const AnswerAProductQuestion = {
render: () => (
<SessionPreview
title="Add the Session review panel"
description={description}
document={questionPendingDocument}
request={{ type: "question", value: activeQuestionRequest }}
/>
),
}
export const ReviewChanges = {
render: () => (
<SessionPreview
title="Update active Session status"
description={description}
document={editThenTestDocument}
reviewOpened
/>
),
}
export const RecoverFromAFailedTest = {
render: () => (
<SessionPreview
title="Keep tool disclosure stable"
description={description}
document={recoveryDocument}
draft="Also run the App browser test"
/>
),
}
export const WorkFromAttachments = {
render: () => (
<SessionPreview
@@ -149,9 +109,99 @@ export const MixedDirectionRtl = {
render: () => (
<SessionPreview
title="مراجعة واجهة Session"
description="opencode · packages/app/src/session.tsx"
description="opencode · packages/app/src/session/screen.tsx"
document={attachmentsAndCommentsDocument}
draft="راجع المسار packages/app/src/session.tsx ثم شغّل bun test"
draft="راجع المسار packages/app/src/session/screen.tsx ثم شغّل bun test"
/>
),
}
export const EmptySession = {
render: () => <SessionPreview title="New Session" description={description} document={emptySessionDocument} />,
}
export const StreamingSession = {
render: () => (
<SessionPreview title="Stream the Session response" description={description} document={streamingDocument} />
),
}
export const EditThenShellTest = {
render: implementAndVerify,
}
export const PermissionRequest = {
render: () => (
<SessionPreview
title="Publish canary preview"
description={description}
document={permissionPendingDocument}
request={{ type: "permission", value: activePermissionRequest }}
/>
),
}
export const QuestionRequest = {
render: () => (
<SessionPreview
title="Add the Session review panel"
description={description}
document={questionPendingDocument}
request={{ type: "question", value: activeQuestionRequest }}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
),
}
export const ReviewPlusTerminal = {
render: () => (
<SessionPreview
title="Review the terminal verification"
description={description}
document={{ ...terminalPassedDocument, diffs: editThenTestDocument.diffs }}
reviewOpened
terminal={{ title: "Terminal 1", lines: ["$ bun test", "27 pass", "0 fail"] }}
/>
),
}
export const ChildSession = {
render: () => (
<SessionPreview
title="Inspect the child Session"
description={description}
document={subagentDocument}
child={{ parentID: "ses_story_parent" }}
/>
),
}
export const MobileLayout = {
globals: { viewport: { value: "mobile1", isRotated: false } },
parameters: { viewport: { defaultViewport: "mobile1" } },
render: () => (
<SessionPreview
title="Review the mobile Session"
description={description}
document={attachmentsAndCommentsDocument}
/>
),
}
export const NarrowLayout = {
globals: { viewport: { value: "tablet", isRotated: false } },
parameters: { viewport: { defaultViewport: "tablet" } },
render: () => (
<SessionPreview
title="Review the narrow Session"
description={description}
document={editThenTestDocument}
reviewOpened
/>
),
}
@@ -1,11 +1,11 @@
import type { ModelSelection } from "@/context/local"
import { PromptInputV2Composer, type PromptInputV2ComposerController } from "@/components/prompt-input-v2"
import { SessionHeaderV2Actions } from "@/components/session/session-header-actions"
import { SessionHeaderActions } from "@/session/header/session-header-actions"
import {
SessionComposerRegion,
type SessionComposerRegionViewController,
} from "@/pages/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame"
} from "@/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionDocument } from "@opencode-ai/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook"
@@ -19,8 +19,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { ReviewPanelV2View } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -84,6 +85,8 @@ export type SessionPreviewProps = {
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
}
export function SessionPreview(props: SessionPreviewProps) {
@@ -230,8 +233,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
openParent: () => setState("activity", "Opened the parent Session locally"),
setPromptRef() {},
setDockRef() {},
parentID: () => undefined,
child: () => false,
parentID: () => props.child?.parentID,
child: () => !!props.child,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
@@ -274,8 +277,14 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
/>
</section>
<Show when={state.reviewOpened}>
<aside id="review-panel" class="min-w-0 flex-1 border-l border-border-weak-base md:max-w-[52%]">
<SessionReviewPane diffs={props.document.diffs} />
<aside
id="review-panel"
class="min-w-0 flex-1 flex flex-col gap-2 border-l border-border-weak-base md:max-w-[52%]"
>
<div class="min-h-0 flex-1">
<SessionReviewPane diffs={props.document.diffs} />
</div>
<Show when={props.terminal}>{(terminal) => <SessionTerminalPreview terminal={terminal()} />}</Show>
</aside>
</Show>
</div>
@@ -290,6 +299,32 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
)
}
function SessionTerminalPreview(props: { terminal: NonNullable<SessionPreviewProps["terminal"]> }) {
return (
<TerminalSurface
label={props.terminal.title}
opened
desktop
stacked
height="220px"
contentHeight="220px"
pane={220}
max={360}
resizing={false}
onResizeStart={() => undefined}
onResize={() => undefined}
onCollapse={() => undefined}
>
<div class="h-10 shrink-0 flex items-center border-b border-border-weaker-base px-3 text-13-medium text-text-strong">
{props.terminal.title}
</div>
<pre dir="ltr" class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-12-regular text-text-base">
{props.terminal.lines.join("\n")}
</pre>
</TerminalSurface>
)
}
function SessionSurfaceHeader(props: {
title: string
description: string
@@ -311,7 +346,7 @@ function SessionSurfaceHeader(props: {
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<SessionHeaderV2Actions
<SessionHeaderActions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: [],
@@ -330,13 +365,13 @@ function SessionSurfaceHeader(props: {
function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
const language = useLanguage()
const review = createReviewPanelV2State()
const review = createReviewPanelState()
const [state, setState] = createStore({
active: props.diffs[0]?.file,
diffStyle: "unified" as "unified" | "split",
})
return (
<ReviewPanelV2View
<ReviewPanelView
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
+305
View File
@@ -0,0 +1,305 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Keybind } from "@opencode-ai/ui/keybind"
import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal } from "@/context/terminal"
import { useWorkspaceLocation } from "@/context/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
import { createSizing, focusTerminalById } from "@/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { TerminalSurface } from "./surface"
export function TerminalPanel(props: { stacked?: boolean } = {}) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
const command = useCommand()
const { workspaceKey, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({
autoCreated: false,
recovered: {} as Record<string, boolean>,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
const stacked = createMemo(() => isDesktop() && !!props.stacked)
const panelHeight = createMemo(() =>
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
)
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
onMount(() => {
if (typeof window === "undefined") return
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
const port = window.visualViewport
sync()
makeEventListener(window, "resize", sync)
if (port) makeEventListener(port, "resize", sync)
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
if (!opened()) return
close()
},
),
)
createEffect(
on(
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
([next, id, requested]) => {
if (!next || !id || !requested) return
focusTerminalById(id)
},
),
)
createEffect(() => {
if (opened()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
createEffect(() => {
const dir = sdk().directory
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
workspaceKey(),
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = sdk().directory
if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? []
})
const all = terminal.all
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
if (store.recovered[key]) return
setStore("recovered", key, true)
void clone(id)
}
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
return String(pty.titleNumber || pty.title || pty.id)
}
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
setStore("recovered", key, false)
trim(id)
}
const handleTerminalDragEnd = () => {
const activeId = terminal.active()
if (!activeId) return
requestAnimationFrame(() => {
if (terminal.active() !== activeId) return
focusTerminalById(activeId)
})
}
return (
<TerminalSurface
ref={(element) => {
root = element
}}
label={language.t("terminal.title")}
opened={opened()}
desktop={isDesktop()}
stacked={stacked()}
height={panelHeight()}
contentHeight={contentHeight()}
pane={pane()}
max={max()}
resizing={size.active()}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
!!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
terminal.move(source.id.toString(), source.index)
}
handleTerminalDragEnd()
}}
>
<div class="flex flex-col h-full">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-[52px] !flex-none"
>
<Tabs.List
ref={tabList}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}>
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={opened() && terminal.active()} keyed>
{(id) => {
const ops = terminal.bind()
return (
<Show when={all().find((pty) => pty.id === id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
autoFocus={terminal.focusRequested(id)}
onAutoFocus={() => terminal.consumeFocus(id)}
class="!px-[14px]"
onConnect={() =>
markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) =>
ops.trim(terminalID),
)
}
onCleanup={(terminal) => ops.update(terminal)}
onConnectError={() =>
recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID))
}
/>
</div>
)}
</Show>
)
}}
</Show>
</div>
</div>
</DragDropProvider>
</Show>
</TerminalSurface>
)
}
@@ -0,0 +1,64 @@
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import type { ParentProps } from "solid-js"
export function TerminalSurface(
props: ParentProps<{
label: string
opened: boolean
desktop: boolean
stacked: boolean
height: string
contentHeight: string
pane: number
max: number
resizing: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
ref?: (element: HTMLElement) => void
}>,
) {
return (
<aside
ref={props.ref}
id="terminal-panel"
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
inert={!props.opened}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !props.desktop || props.stacked,
"min-w-0 h-full flex-1": props.desktop && props.opened && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !props.opened,
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop,
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!props.desktop && !props.resizing,
}}
style={{ height: props.height }}
>
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
<ResizeHandle
class="-top-1"
direction="vertical"
size={props.pane}
min={100}
max={props.max}
collapseThreshold={50}
onResize={props.onResize}
onCollapse={props.onCollapse}
/>
</div>
<div
class="absolute inset-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": props.opened && !props.desktop,
"pointer-events-none": !props.opened,
}}
style={{ height: props.contentHeight }}
>
{props.children}
</div>
</aside>
)
}
@@ -4,12 +4,12 @@ import { createStore } from "solid-js/store"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Menu } from "@opencode-ai/ui/menu"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { isDefaultTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers"
import { focusTerminalById } from "@/session/helpers"
export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number; onClose?: () => void }): JSX.Element {
export function SortableTerminalTab(props: { terminal: LocalPTY; index: number; onClose?: () => void }): JSX.Element {
const terminal = useTerminal()
const language = useLanguage()
const sortable = useSortable({
@@ -29,15 +29,15 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => {
const defaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
return isDefaultTerminalTitle(props.terminal.title, number)
return isDefaultTitle(props.terminal.title, number)
}
const label = () => {
language.locale()
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
if (props.terminal.title && !defaultTitle()) return props.terminal.title
const number = props.terminal.titleNumber
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
@@ -131,9 +131,7 @@ export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number
focus()
}}
onMiddleClick={close}
closeButton={
<Tabs.CloseButton class="h-5 w-5" onClick={close} aria-label={language.t("terminal.close")} />
}
closeButton={<Tabs.CloseButton class="h-5 w-5" onClick={close} aria-label={language.t("terminal.close")} />}
hideCloseButton
>
<span
@@ -1,4 +1,4 @@
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { isDefaultTitle } from "@/context/terminal-title"
export const terminalTabLabel = (input: {
title?: string
@@ -7,9 +7,9 @@ export const terminalTabLabel = (input: {
}) => {
const title = input.title ?? ""
const number = input.titleNumber ?? 0
const isDefaultTitle = Number.isFinite(number) && number > 0 && isDefaultTerminalTitle(title, number)
const defaultTitle = Number.isFinite(number) && number > 0 && isDefaultTitle(title, number)
if (title && !isDefaultTitle) return title
if (title && !defaultTitle) return title
if (number > 0) return input.t("terminal.title.numbered", { number })
if (title) return title
return input.t("terminal.title")

Some files were not shown because too many files have changed in this diff Show More