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