Compare commits

..
Author SHA1 Message Date
Shoubhit Dash 95fc4e974d Merge branch 'v2' into stats-cli 2026-08-20 20:50:06 +05:30
Shoubhit Dash dd10815b60 chore(protocol): regenerate openapi 2026-08-20 20:35:15 +05:30
Shoubhit Dash a2287446ce feat(cli): add shareable stats command 2026-08-20 20:34:27 +05:30
192 changed files with 4957 additions and 4878 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@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
@@ -1,37 +0,0 @@
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 }}
+17 -31
View File
@@ -592,9 +592,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.6",
"@opentui/keymap": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"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.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@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.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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@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": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@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": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
"@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-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@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/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/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=="],
"@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=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -6882,8 +6882,6 @@
"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=="],
@@ -8094,17 +8092,6 @@
"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=="],
@@ -9445,7 +9432,6 @@
"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.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -1,100 +0,0 @@
{
"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\"}}"
}
]
}
]
}
@@ -1,119 +0,0 @@
{
"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\"}}"
}
]
}
]
}
@@ -1,129 +0,0 @@
{
"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\"}}"
}
]
}
]
}
@@ -1,216 +0,0 @@
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." }] },
],
})
}),
)
})
+2 -10
View File
@@ -1,7 +1,5 @@
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"
@@ -18,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -71,7 +69,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -83,16 +81,10 @@ 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(/\/api\/pty(?:\?.*)?$/, (route) =>
await page.route("**/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/session/timeline/observe-element-offset"
import { observeElementOffsetReconnectAware } from "../../../src/pages/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("@/session/route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/pages/session"), 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 "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/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 "@/session/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
@@ -10,8 +10,8 @@ import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useWorkspaceLocation } from "@/context/location"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -0,0 +1,5 @@
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"
@@ -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 "@/session/files/open-in-app"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
export function OpenInAppV2(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 "@/session/session-layout"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionContextFormatter } from "./session-context-format"
function Stat(props: { label: string; value: JSX.Element }) {
@@ -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 SessionHeaderActionsState = {
export type SessionHeaderV2ActionsState = {
status?: { label: string; content: () => JSX.Element }
reviewLabel: string
reviewKeybind: string[]
@@ -13,7 +13,7 @@ export type SessionHeaderActionsState = {
onReviewToggle: () => void
}
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
export function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
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 "@/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"
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"
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 actions = createMemo<SessionHeaderActionsState>(() => ({
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
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}>
<SessionHeaderActions state={actions()} />
<SessionHeaderV2Actions state={v2ActionsState()} />
</Portal>
)}
</Show>
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTab(props: {
export function SortableTabV2(props: {
tab: string
index: number
temporary?: boolean
@@ -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 } from "@/context/terminal-title"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/session/helpers"
import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTab(props: { terminal: LocalPTY; index: number; onClose?: () => void }): JSX.Element {
export function SortableTerminalTabV2(props: { terminal: LocalPTY; index: number; onClose?: () => void }): JSX.Element {
const terminal = useTerminal()
const language = useLanguage()
const sortable = useSortable({
@@ -29,15 +29,15 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; index: number;
let blurFrame: number | undefined
let editRequested = false
const defaultTitle = () => {
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
return isDefaultTitle(props.terminal.title, number)
return isDefaultTerminalTitle(props.terminal.title, number)
}
const label = () => {
language.locale()
if (props.terminal.title && !defaultTitle()) return props.terminal.title
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
const number = props.terminal.titleNumber
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
@@ -131,7 +131,9 @@ export function SortableTerminalTab(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
+27 -14
View File
@@ -15,7 +15,9 @@ 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+`"
@@ -177,6 +179,7 @@ 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",
@@ -528,6 +531,14 @@ 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
@@ -551,21 +562,23 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
const socket = await serverSDK.pty
.connect({
ptyID: id,
location: { directory },
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,
cursor: seek,
})
.catch((err) => {
fail(err)
return undefined
})
if (!socket || once.value) return
if (disposed) {
socket.close(1000)
return
}
ticket,
}),
)
socket.binaryType = "arraybuffer"
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; parentID: null }> = []
const calls: Array<{ cursor?: string }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: first, cursor: { next: "next" } }
@@ -29,7 +29,6 @@ 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,12 +6,7 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export async function loadHomeSessionIndex(
list: (
input: {
limit: number
order: "desc"
parentID: null
cursor?: string
},
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<SessionsResponse>,
signal?: AbortSignal,
@@ -24,7 +19,6 @@ export async function loadHomeSessionIndex(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
parentID: null,
...(cursor ? { cursor } : {}),
},
{ signal },
@@ -35,7 +29,8 @@ export async function loadHomeSessionIndex(
}
}
// Keep this filter for locally known sessions merged into the fetched index.
// 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.
export function parseHomeSessionIndex(sessions: SessionInfo[]) {
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
}
+1 -4
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createClientConnection, 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,7 +61,6 @@ type ServerSDKBase = {
scope: ServerScope
url: string
api: ServerApi
pty: ReturnType<typeof createPtyClient>
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
@@ -73,7 +72,6 @@ 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, {
@@ -95,7 +93,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
pty,
connection,
event: events.event,
}
@@ -1,38 +0,0 @@
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) }
})
}
@@ -1,39 +0,0 @@
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,19 +13,20 @@ import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { errorMessage } from "@/pages/layout/helpers"
import { compareSessionTime, displayName, errorMessage, projectForSession } 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
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export type HomeSessionRecord = {
session: SessionInfo
project: LocalProject
projectName: string
}
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -40,11 +41,13 @@ 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()
return project ? directories(project) : [selected]
if (!project) return home.project.list().flatMap(directories)
return directories(project)
})
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()
@@ -58,14 +61,13 @@ 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(),
)
@@ -75,6 +77,7 @@ export function createHomeSessionsController(home: HomeController) {
sessions: indexedSessions,
projectDirectories,
projects: home.project.list,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
@@ -151,7 +154,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)
@@ -199,6 +202,30 @@ 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}`
}
@@ -239,7 +266,6 @@ export function HomeSessionStatusController(props: {
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
return props.render({
unread: avatar.unread,
@@ -1,39 +1,33 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/context/global"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/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(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(
ctx.data.session.list(),
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(sessions(), data.session.form.list, sessionId())
return !!sessionQuestionForm(data.session.list(), 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 "@/session/composer"
import { createPromptModelSelection } from "@/session/composer/prompt-model-selection"
import { useSessionKey } from "@/session/session-layout"
import { useComposerCommands } from "@/session/commands/use-composer-commands"
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"
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/new-session/new-session-layout"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/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
@@ -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/session-composer-pullout"
import { SessionComposerPullout } from "./session-composer-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
@@ -1,3 +1,4 @@
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 "@/session/handoff"
import { getSessionHandoff, setSessionHandoff } from "@/pages/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 "@/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 { 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 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/requests/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -7,9 +7,11 @@ 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/requests/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "./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 "@/session/composer/session-revert-dock"
import { SessionRevertDock } from "@/pages/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/session/composer/session-revert-dock.tsx\`
- \`packages/app/src/pages/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, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, 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 "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/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}>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
@@ -119,7 +119,7 @@ export const createOpenReviewFile = (input: {
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => unknown
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
@@ -1,11 +1,8 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createMediaQuery } from "@solid-primitives/media"
import { createMemo } from "solid-js"
import { createMemo, type Accessor } 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,
@@ -20,12 +17,14 @@ const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
const idle = { type: "idle" as const }
export function useSessionModel() {
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
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()
@@ -51,34 +50,17 @@ export function useSessionModel() {
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: isDesktop,
hasReview: canReview,
fileBrowser: () => isDesktop() && !!sessionID(),
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
})
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,
@@ -116,4 +98,4 @@ export function useSessionModel() {
}
}
export type SessionModel = ReturnType<typeof useSessionModel>
export type SessionController = ReturnType<typeof createSessionController>
@@ -2,7 +2,6 @@ import {
activePermissionRequest,
activeQuestionRequest,
attachmentsAndCommentsDocument,
compactionDocument,
editThenTestDocument,
emptySessionDocument,
largeCompletedDocument,
@@ -10,32 +9,12 @@ import {
permissionPendingDocument,
questionPendingDocument,
queuedPrompts,
retryDocument,
streamingDocument,
STORY_TIME,
subagentDocument,
terminalPassedDocument,
recoveryDocument,
thinkingDocument,
} from "@opencode-ai/session-ui/storybook"
import { SessionPreview } from "./story-model"
import { SessionPreview } from "./session-preview"
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"
@@ -60,6 +39,23 @@ 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,
@@ -82,6 +78,50 @@ 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
@@ -109,99 +149,9 @@ export const MixedDirectionRtl = {
render: () => (
<SessionPreview
title="مراجعة واجهة Session"
description="opencode · packages/app/src/session/screen.tsx"
description="opencode · packages/app/src/session.tsx"
document={attachmentsAndCommentsDocument}
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
draft="راجع المسار packages/app/src/session.tsx ثم شغّل bun test"
/>
),
}
@@ -1,11 +1,11 @@
import type { ModelSelection } from "@/context/local"
import { PromptInputV2Composer, type PromptInputV2ComposerController } from "@/components/prompt-input-v2"
import { SessionHeaderActions } from "@/session/header/session-header-actions"
import { SessionHeaderV2Actions } from "@/components/session/session-header-actions"
import {
SessionComposerRegion,
type SessionComposerRegionViewController,
} from "@/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
} from "@/pages/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/pages/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,9 +19,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import { ReviewPanelV2View } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -85,8 +84,6 @@ 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) {
@@ -233,8 +230,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
openParent: () => setState("activity", "Opened the parent Session locally"),
setPromptRef() {},
setDockRef() {},
parentID: () => props.child?.parentID,
child: () => !!props.child,
parentID: () => undefined,
child: () => false,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
@@ -277,14 +274,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
/>
</section>
<Show when={state.reviewOpened}>
<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 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>
</Show>
</div>
@@ -299,32 +290,6 @@ 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
@@ -346,7 +311,7 @@ function SessionSurfaceHeader(props: {
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<SessionHeaderActions
<SessionHeaderV2Actions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: [],
@@ -365,13 +330,13 @@ function SessionSurfaceHeader(props: {
function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
const language = useLanguage()
const review = createReviewPanelState()
const review = createReviewPanelV2State()
const [state, setState] = createStore({
active: props.diffs[0]?.file,
diffStyle: "unified" as "unified" | "split",
})
return (
<ReviewPanelView
<ReviewPanelV2View
title={language.t("ui.sessionReview.title.lastTurn")}
empty={<SessionReviewEmptyChangesV2 />}
diffs={props.diffs}
@@ -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, PointerSensor } from "@dnd-kit/solid"
import { DragDropProvider as DndKitProvider, 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,31 +16,30 @@ 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 "@/session/timeline/session-context-usage"
import { SessionContextUsage } from "@/components/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 } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { SessionContextTab, SortableTabV2 } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
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 "@/session/files/file-tab-scroll"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
type Sizing,
} 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"
} 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"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
@@ -262,7 +261,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">
<DragDropProvider
<DndKitProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
@@ -346,7 +345,7 @@ export function SessionSidePanel(props: {
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
<SortableTabV2
tab={tab}
index={tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
@@ -415,7 +414,7 @@ export function SessionSidePanel(props: {
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<OpenInAppV2 directory={projectDirectory} />
</div>
</div>
@@ -477,7 +476,7 @@ export function SessionSidePanel(props: {
</div>
</Show>
</Tabs>
</DragDropProvider>
</DndKitProvider>
</div>
</div>
</Show>
@@ -1,4 +1,4 @@
import { isDefaultTitle } from "@/context/terminal-title"
import { isDefaultTitle as isDefaultTerminalTitle } 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 defaultTitle = Number.isFinite(number) && number > 0 && isDefaultTitle(title, number)
const isDefaultTitle = Number.isFinite(number) && number > 0 && isDefaultTerminalTitle(title, number)
if (title && !defaultTitle) return title
if (title && !isDefaultTitle) return title
if (number > 0) return input.t("terminal.title.numbered", { number })
if (title) return title
return input.t("terminal.title")
@@ -0,0 +1,322 @@
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>
)
}
@@ -11,7 +11,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs } from "@/context/tabs"
import type { SessionModel } from "@/session/model"
import type { SessionController } from "@/pages/session/session-controller"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
@@ -38,9 +38,9 @@ const taskDescription = (message: SessionMessageInfo, sessionID: string): string
}
export type TimelineSessionSource = {
identity: Pick<SessionModel["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionModel["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionModel["history"], "messages">
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: { session: TimelineSessionSource }) {
@@ -11,8 +11,8 @@ import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { Button } from "@opencode-ai/ui/button"
import type { Project } from "@/types"
import { getFilename } from "@opencode-ai/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
@@ -21,7 +21,7 @@ import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeli
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/utils/workspace"
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
@@ -480,8 +480,8 @@ function MessageTimelineView(
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover.Trigger
<KobaltePopover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<KobaltePopover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
@@ -490,8 +490,8 @@ function MessageTimelineView(
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<KobaltePopover.Portal>
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
directory={sessionDirectory()}
@@ -508,9 +508,9 @@ function MessageTimelineView(
props.onReview()
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)}
</Show>
<Show when={!parentID()}>
@@ -583,7 +583,7 @@ function MessageTimelineView(
</Menu.Portal>
</Menu>
<Popover
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
@@ -594,8 +594,8 @@ function MessageTimelineView(
setShare("open", open)
}}
>
<Popover.Portal>
<Popover.Content
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
class="flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]"
style={{ "min-width": "320px" }}
@@ -688,9 +688,9 @@ function MessageTimelineView(
</div>
</Show>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</Show>
</div>
)}
@@ -1,14 +1,14 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo, createResource, type Accessor } from "solid-js"
import { useData } from "@/context/server"
import type { SessionModel } from "../model"
import type { SessionController } from "../session-controller"
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
const data = useData()
const [resource] = createResource(
@@ -1,7 +1,7 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/session/message-gesture"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture"
import { useLanguage } from "@/context/language"
import {
createEffect,
@@ -75,7 +75,7 @@ export function useUsageExceededDialogs() {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("@/components/dialog-connect-provider").then((x) => {
void import("../../components/dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
@@ -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/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./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 { SessionModel } from "@/session/model"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
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">
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">
}
export type SessionCommandContext = {
@@ -63,7 +63,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionModel["ownership"]["capture"]>
owner: ReturnType<SessionController["ownership"]["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
@@ -116,6 +116,82 @@ 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
@@ -297,6 +373,36 @@ 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",
@@ -488,6 +594,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),

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