From a79cd8620c01d8b0e47422b8df50edf5a62051a2 Mon Sep 17 00:00:00 2001 From: lycaon Date: Mon, 13 Jul 2026 17:35:51 -0600 Subject: [PATCH] feat: complete remote session management for v0.1.6 --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/deploy-site.yml | 91 ++- .github/workflows/release.yml | 96 ++- FEATURE_MATRIX.md | 6 +- README.md | 36 +- SECURITY.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/target-manager.ts | 2 +- apps/site/package.json | 2 +- apps/site/src/docs/content.ts | 42 +- apps/site/src/release.ts | 18 +- apps/site/src/site.css | 6 +- apps/site/test/release.test.ts | 43 +- apps/web/package.json | 2 +- apps/web/src/components/AppShell.tsx | 42 +- apps/web/src/components/CollapsedRail.tsx | 60 ++ apps/web/src/components/Rail.tsx | 570 ++++++++++--- apps/web/src/components/SessionListTabs.tsx | 72 ++ apps/web/src/components/SessionScreen.tsx | 24 +- apps/web/src/features/composer/Composer.tsx | 10 +- .../session-runtime/frame-builders.ts | 15 - .../features/session-runtime/live-runtime.ts | 43 +- .../session-event-vocabulary.ts | 6 + .../session-runtime/session-management.ts | 388 +++++++++ .../session-runtime/session-navigation.ts | 45 ++ .../src/features/transcript/SessionMain.tsx | 122 +-- .../transcript/TranscriptTimeline.tsx | 18 +- .../web/src/features/transcript/projection.ts | 13 +- apps/web/src/features/transcript/rows.ts | 15 + apps/web/src/lib/host-target.ts | 21 + apps/web/src/lib/session-route.ts | 180 +++++ apps/web/src/lib/session-tree.ts | 16 +- apps/web/src/lib/workspace-data.ts | 3 + apps/web/src/platform/browser-shell-port.ts | 2 +- apps/web/src/platform/live-workspace.ts | 28 +- apps/web/src/router.tsx | 111 ++- apps/web/src/state/workspace-store.ts | 8 + apps/web/test/live-create.test.ts | 36 +- apps/web/test/live-session-controls.test.ts | 86 +- apps/web/test/mobile-touch-targets.test.tsx | 2 +- apps/web/test/router-fallback.test.ts | 370 +++++++++ apps/web/test/session-management.test.ts | 365 +++++++++ apps/web/test/session-navigation.test.ts | 76 ++ apps/web/test/session-tree.test.ts | 18 + apps/web/test/transcript-projection.test.ts | 35 +- compat/omp-app-matrix.json | 32 +- docs/RELEASE_GATE.md | 40 + e2e/cold-mount-observer.ts | 52 ++ e2e/remote-app.spec.ts | 376 +++++++-- package.json | 2 +- packages/client/package.json | 2 +- packages/client/src/desktop-runtime-hosts.ts | 133 +++ packages/client/src/desktop-runtime-policy.ts | 2 + packages/client/src/desktop-runtime.ts | 58 +- packages/client/src/omp-client-frames.ts | 2 +- packages/client/src/omp-client-outbound.ts | 43 + packages/client/src/omp-client-runtime.ts | 57 +- packages/client/src/projection-cache.ts | 24 +- packages/client/src/projection-sanitize.ts | 109 +++ packages/client/src/projection.ts | 163 ++-- packages/client/test/client.test.ts | 17 +- packages/client/test/desktop-runtime.test.ts | 57 ++ packages/client/test/projection.test.ts | 240 +++++- packages/fixture-server/package.json | 2 +- packages/fixture-server/src/engine.ts | 758 +++++++++--------- .../fixture-server/src/fixture-catalog.ts | 66 ++ .../src/fixture-command-frames.ts | 139 ++++ .../fixture-server/src/fixture-sessions.ts | 330 ++++++++ .../fixture-server/src/virtual-scheduler.ts | 47 ++ packages/fixture-server/src/ws.ts | 1 + packages/fixture-server/test/engine.test.ts | 480 ++++++++++- packages/fixture-server/test/ws.test.ts | 184 +++++ packages/protocol/package.json | 4 +- packages/protocol/src/desktop-ipc.ts | 8 +- packages/protocol/test/desktop-ipc.test.ts | 43 + packages/protocol/test/distribution.test.ts | 14 +- .../protocol/test/session-management.test.ts | 93 +++ packages/remote/package.json | 2 +- packages/service-manager/package.json | 2 +- packages/ui/package.json | 2 +- pnpm-lock.yaml | 12 +- .../t3code/imports/f1-shell-20260711.json | 4 +- .../imports/f2-transcript-20260711.json | 4 +- scripts/check-release-consistency.mjs | 265 +++++- scripts/check-release-consistency.test.mjs | 106 ++- scripts/inspect-macos-dmg.test.mjs | 2 +- scripts/wait-for-release-assets.test.mjs | 16 +- vendor/app-wire/manifest.json | 14 +- vendor/app-wire/oh-my-pi-app-wire-0.5.1.tgz | Bin 27941 -> 0 bytes vendor/app-wire/oh-my-pi-app-wire-0.5.2.tgz | Bin 0 -> 29394 bytes 91 files changed, 6084 insertions(+), 1075 deletions(-) create mode 100644 apps/web/src/components/CollapsedRail.tsx create mode 100644 apps/web/src/components/SessionListTabs.tsx create mode 100644 apps/web/src/features/session-runtime/session-management.ts create mode 100644 apps/web/src/features/session-runtime/session-navigation.ts create mode 100644 apps/web/src/lib/host-target.ts create mode 100644 apps/web/src/lib/session-route.ts create mode 100644 apps/web/test/router-fallback.test.ts create mode 100644 apps/web/test/session-management.test.ts create mode 100644 apps/web/test/session-navigation.test.ts create mode 100644 docs/RELEASE_GATE.md create mode 100644 e2e/cold-mount-observer.ts create mode 100644 packages/client/src/desktop-runtime-hosts.ts create mode 100644 packages/client/src/omp-client-outbound.ts create mode 100644 packages/client/src/projection-sanitize.ts create mode 100644 packages/fixture-server/src/fixture-catalog.ts create mode 100644 packages/fixture-server/src/fixture-command-frames.ts create mode 100644 packages/fixture-server/src/fixture-sessions.ts create mode 100644 packages/fixture-server/src/virtual-scheduler.ts create mode 100644 packages/protocol/test/session-management.test.ts delete mode 100644 vendor/app-wire/oh-my-pi-app-wire-0.5.1.tgz create mode 100644 vendor/app-wire/oh-my-pi-app-wire-0.5.2.tgz diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7fb2cf10..0427d48c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.5" + placeholder: "0.1.6" validations: required: true - type: dropdown diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 165b01f1..62435f08 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -11,6 +11,11 @@ on: - "pnpm-lock.yaml" - ".github/workflows/deploy-site.yml" workflow_dispatch: + inputs: + release_tag: + description: Published release tag whose immutable source must be deployed. + required: true + type: string permissions: contents: read @@ -22,14 +27,29 @@ concurrency: jobs: deploy: + if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-24.04 timeout-minutes: 50 environment: name: production url: https://t4code.net steps: - - name: Check out source + - name: Check out trusted workflow source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Read trusted main release version + id: source + shell: bash + env: + MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + printf 'version=%s\nmain_sha=%s\n' "$version" "$MAIN_SHA" >> "$GITHUB_OUTPUT" - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -42,19 +62,84 @@ jobs: node-version: 24.13.1 cache: pnpm - - name: Wait for the complete public release - run: node scripts/wait-for-release-assets.mjs --timeout-ms 2400000 --interval-ms 15000 + - name: Confirm the published release from the release workflow + id: published_release + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + RELEASE_VERSION: ${{ steps.source.outputs.version }} + run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000 + + - name: Check whether an ordinary main push references an existing release + id: existing_release + if: ${{ github.event_name == 'push' }} + continue-on-error: true + env: + RELEASE_VERSION: ${{ steps.source.outputs.version }} + run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000 + + - name: Defer a release-version site update until publication + if: ${{ github.event_name == 'push' && steps.existing_release.outcome == 'failure' }} + run: echo "The referenced release is not public yet; the release workflow will deploy this site after publication." + + - name: Resolve immutable deployment source + id: immutable_source + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + MAIN_SHA: ${{ steps.source.outputs.main_sha }} + REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} + TRUSTED_VERSION: ${{ steps.source.outputs.version }} + run: | + set -euo pipefail + expected_tag="v${TRUSTED_VERSION}" + release_tag="$expected_tag" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then + echo "release_tag must be the current release ${expected_tag}" >&2 + exit 1 + fi + release_tag="$REQUESTED_RELEASE_TAG" + fi + release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv') + if [[ "$release_flags" != $'false\tfalse' ]]; then + echo "release_tag must name a published, non-prerelease GitHub release" >&2 + exit 1 + fi + git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}" + source_sha=$(git rev-parse "${release_tag}^{commit}") + tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') + if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then + echo "release tag package version ${tag_version} does not match trusted main ${TRUSTED_VERSION}" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$source_sha" "$MAIN_SHA"; then + echo "release tag source is not reachable from trusted main" >&2 + exit 1 + fi + printf 'release_tag=%s\nsource_sha=%s\n' "$release_tag" "$source_sha" >> "$GITHUB_OUTPUT" + + - name: Check out immutable deployment source + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ steps.immutable_source.outputs.source_sha }} + persist-credentials: false - name: Install dependencies + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} run: pnpm install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: us-east-1 - name: Build and deploy static site + if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} env: T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 447699e6..32370481 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,57 @@ env: jobs: verify: + if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-24.04 timeout-minutes: 30 + outputs: + source_sha: ${{ steps.source.outputs.source_sha }} + version: ${{ steps.source.outputs.version }} steps: - - name: Check out release tag + - name: Check out trusted release-control source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve immutable release source + id: source + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + trusted_version=$(node -p "require('./package.json').version") + expected_tag="v${trusted_version}" + if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then + echo "release tag must be the current package tag ${expected_tag}" >&2 + exit 1 + fi + git fetch --force --no-tags origin "refs/heads/main:refs/remotes/origin/main" + git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + source_sha=$(git rev-parse "${RELEASE_TAG}^{commit}") + tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') + if [[ "$tag_version" != "$trusted_version" ]]; then + echo "release tag package version ${tag_version} does not match trusted source ${trusted_version}" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main; then + echo "release tag source is not reachable from main" >&2 + exit 1 + fi + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MAIN_SHA" != "$(git rev-parse HEAD)" ]]; then + echo "manual releases must run from the checked-out main commit" >&2 + exit 1 + fi + printf 'source_sha=%s\nversion=%s\n' "$source_sha" "$tag_version" >> "$GITHUB_OUTPUT" + + - name: Check out immutable release source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ steps.source.outputs.source_sha }} + persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -74,10 +118,11 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 35 steps: - - name: Check out release tag + - name: Check out verified release source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -119,10 +164,11 @@ jobs: runs-on: macos-15 timeout-minutes: 40 steps: - - name: Check out release tag + - name: Check out verified release source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -162,12 +208,28 @@ jobs: retention-days: 7 publish: - needs: [build-linux, build-macos] + needs: [verify, build-linux, build-macos] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write steps: + - name: Check out verified release source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.verify.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Confirm the release tag still resolves to the verified source + shell: bash + env: + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + run: | + set -euo pipefail + git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$SOURCE_SHA" + - name: Download built artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -192,7 +254,7 @@ jobs: ## Runtime compatibility - This release vendors app-wire 0.5.1 and was verified with OMP 16.4.8 built from [f65bb379](https://github.com/lyc-aon/oh-my-pi/commit/f65bb37970d2186f04ec4b650eb0b53ec3b1337b). Stock upstream OMP v16.4.8 does not include that bounded large-session snapshot and replay fix. It remains protocol-compatible, but a very large active session can disconnect while attaching. + This release vendors app-wire 0.5.2 from public integration commit [5d4315ee](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6) and was verified with OMP 16.4.8 built from [932bbace](https://github.com/lyc-aon/oh-my-pi/commit/932bbaceb256f43eb3b2760341f2175803da4d07), tagged [t4code-16.4.8-appserver-4](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.4.8-appserver-4). That runtime adds bounded growing-session replay, complete session event projection, catalog-backed session lifecycle management, ordered remote outbound frames, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. Official upstream OMP v16.4.8 has no `appserver` command and cannot host T4 Code. The verified runtime is built normally from the public `lyc-aon/oh-my-pi` source; it does not require private home-directory files, an auth broker, or a custom Codex CLI fork. The macOS build is unsigned and unnotarized. Gatekeeper will block the first launch. After copying T4 Code to Applications, run: @@ -207,3 +269,21 @@ jobs: artifacts/T4-Code-*.dmg artifacts/T4-Code-*.zip artifacts/SHA256SUMS.txt + + dispatch-site: + name: Dispatch site deployment after release publication + needs: publish + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Dispatch the production workflow from main with exact release source + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: >- + gh workflow run deploy-site.yml + --ref main + -f release_tag="$RELEASE_TAG" diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 9c581207..590e04fb 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -1,6 +1,8 @@ -# OMP Desktop Feature and Surface Matrix +# OMP Desktop Product Surface Map -This matrix is the parity contract. T3 Code is the presentation and interaction reference. OMP is the behavioral authority. A feature is not complete because a button exists; the listed runtime state, transitions, errors, permissions, and proof must work. +This file maps product ideas to their OMP authority and intended T4 Code surface. It is a design and ownership reference, not a list of features shipped in the current release. A row can describe planned work, partial work, or verified behavior; its presence here is not completion proof. + +The README and release notes are the release contract. They must only claim behavior exercised by the current build. OMP remains the behavioral authority, and T3 Code remains a presentation and interaction reference where noted below. ## 1. Hosts, connections, and environments diff --git a/README.md b/README.md index 1d1f5300..3b08108e 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,37 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.5**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.5) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.6**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.6) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. Install OMP first: . +T4 Code needs an OMP build with desktop appserver support. For v0.1.6, use the public integration build below. -T4 Code v0.1.5 was verified with OMP 16.4.8 built from [`f65bb379`](https://github.com/lyc-aon/oh-my-pi/commit/f65bb37970d2186f04ec4b650eb0b53ec3b1337b). That build bounds snapshots and replay payloads for large, growing sessions. The stock upstream v16.4.8 tag does not contain this appserver fix; it remains protocol-compatible, but a very large active session can disconnect while attaching. T4 Code's vendored protocol package remains `@oh-my-pi/app-wire` 0.5.1. +T4 Code v0.1.6 was verified with OMP 16.4.8 built from [`932bbace`](https://github.com/lyc-aon/oh-my-pi/commit/932bbaceb256f43eb3b2760341f2175803da4d07), tagged [`t4code-16.4.8-appserver-4`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.4.8-appserver-4). That integration build adds bounded large-session replay, complete desktop runtime events, catalog-backed session management, ordered remote delivery, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. The official upstream v16.4.8 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.2 from integration commit [`5d4315ee`](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6), source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`. | Platform | Arch | Package | | --- | --- | --- | | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | -No Windows build and no Intel Mac build in v0.1.5. +No Windows build and no Intel Mac build in v0.1.6. -## What changed in v0.1.5 +## What changed in v0.1.6 -- Reconnect history now resets only after the host answers a matching heartbeat. If a server repeatedly drops during post-welcome session replay, T4 Code reaches its retry limit instead of reconnecting forever. -- The verified OMP build bounds both growing-session snapshots and accumulated replay frames. Large active sessions attach with a compacted recent transcript instead of overflowing the appserver WebSocket backpressure limit. Stock upstream OMP v16.4.8 does not include this fix. -- The v0.1.4 mobile work remains in this build. The model picker scrolls by touch and follows the connected profile's `Ctrl+P` cycle. Close and New session have separate 44-pixel controls, and model changes finish before the next prompt is sent. -- The Tailnet gateway still removes half-open browser sockets within 60 seconds. The verified 320 × 568 mobile path can create a session, choose a model, send a prompt, receive the reply, and retain the same durable transcript after reloads. +- Working folders now have Current and Archived views. Sessions can be renamed, archived, restored, or permanently deleted; archived sessions are read-only, and deletion requires the exact title plus the host's current revision. +- Desktop and Tailnet clients receive one host-wide session index. A change made in one client appears in the other, and stale routes recover instead of leaving an empty or endless loading screen. +- The activity stream recognizes the full OMP runtime vocabulary, including turn boundaries and session lifecycle events. Durable history remains stable across reconnects, reloads, and web-to-desktop handoffs. +- The mobile model picker follows the profile's actual `Ctrl+P` cycle, drag-scrolls under touch, and waits for the host-confirmed model before sending the next prompt. The close and new-session controls no longer overlap. +- The verified OMP build bounds large-session replay, preserves remote frame order, settles terminal streaming state before lifecycle changes, and refuses changes while work or an unkillable child is still active. +- The Tailnet browser path needs no separate T4 password. Tailscale Serve remains the access boundary; Funnel must stay off. ## Install ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.5/T4-Code-0.1.5-linux-amd64.deb -sudo apt install ./T4-Code-0.1.5-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.6/T4-Code-0.1.6-linux-amd64.deb +sudo apt install ./T4-Code-0.1.6-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -40,17 +42,17 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.5/T4-Code-0.1.5-linux-x86_64.AppImage -chmod +x T4-Code-0.1.5-linux-x86_64.AppImage -./T4-Code-0.1.5-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.6/T4-Code-0.1.6-linux-x86_64.AppImage +chmod +x T4-Code-0.1.6-linux-x86_64.AppImage +./T4-Code-0.1.6-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) > [!WARNING] -> **The macOS v0.1.5 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. +> **The macOS v0.1.6 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. -1. Download [`T4-Code-0.1.5-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.5/T4-Code-0.1.5-mac-arm64.dmg) (or [`T4-Code-0.1.5-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.5/T4-Code-0.1.5-mac-arm64.zip)). +1. Download [`T4-Code-0.1.6-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.6/T4-Code-0.1.6-mac-arm64.dmg) (or [`T4-Code-0.1.6-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.6/T4-Code-0.1.6-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle: @@ -62,7 +64,7 @@ chmod +x T4-Code-0.1.5-linux-x86_64.AppImage ## What the app does -- **Sessions.** Browse projects and sessions on a host, create new ones, and switch between them. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. +- **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. - **Composer.** Send prompts, use slash commands (`/model`, `/compact`, `/retry`, `/review`, `/terminal`, and more), and change the session's model, thinking level, or fast mode inline. - **Panes.** Watch subagents (and cancel them), apply reviews, browse and preview files on the host, and attach to live terminals with real keyboard input and resize. - **Settings.** Edit host settings over the wire. Drafts stage locally and only apply when the host confirms; a dropped connection never silently writes anything. diff --git a/SECURITY.md b/SECURITY.md index fa13f8c8..3ead6dce 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.5 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- The macOS v0.1.6 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 51559415..84035b69 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd ## Oh My Pi -Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.1` package is packed from the public `lyc-aon/oh-my-pi` integration commit `b69b07ffef6b482447c37bed9a0c734b6711a721`, source tree `da95aa7b76cf089e64bd65e800e6ef75a1787134`; tarball SHA-256 `e347b33de1faffa701bcef668d6d64b211d5c73c585c95cd490f08189d5c924a`; golden corpus SHA-256 `d183c8d99721920a3aee66f29c50183c232315b1c2c9d07dcc8f079d5e92ab6c`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. +Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.2` package is packed from the public `lyc-aon/oh-my-pi` integration commit `5d4315eea317260fec030e2b4726f10fed0cd5f6`, source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`; tarball SHA-256 `fb9b608d7a2245001c334808475fabd9e05603729f48cff88b21a5165b4fb63a`; golden corpus SHA-256 `36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9aa9ec1c..3986ff24 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index a0dc4858..23ee6766 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -324,7 +324,7 @@ export class DesktopTargetManager { cursorStore: this.cursorStoreFactory(targetId), capabilities: requestedCapabilities, requestedFeatures: ADDITIVE_FEATURES, - client: { name: "T4 Code", version: "0.1.5", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.6", build: "desktop", platform: process.platform }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/site/package.json b/apps/site/package.json index 0b4969b6..2e033c0b 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index 87952625..a9668537 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -3,9 +3,11 @@ // derive from this one structure. import { + APP_WIRE_VERSION, OMP_URL, - OMP_RUNTIME_FIX_COMMIT, - OMP_RUNTIME_FIX_URL, + OMP_RUNTIME_COMMIT, + OMP_RUNTIME_TAG, + OMP_RUNTIME_URL, OMP_RUNTIME_VERSION, RELEASE_ASSETS, RELEASE_TAG, @@ -98,11 +100,11 @@ const install: DocTopic = { }, { kind: "p", - text: `T4 Code v${RELEASE_VERSION} was verified with OMP ${OMP_RUNTIME_VERSION} built from [\`${OMP_RUNTIME_FIX_COMMIT.slice(0, 8)}\`](${OMP_RUNTIME_FIX_URL}). That build bounds snapshots and replay payloads for large, growing sessions. T4 Code vendors app-wire 0.5.1.`, + text: `T4 Code v${RELEASE_VERSION} was verified with OMP ${OMP_RUNTIME_VERSION} integration tag [\`${OMP_RUNTIME_TAG}\`](${OMP_RUNTIME_URL}), commit \`${OMP_RUNTIME_COMMIT}\`. The build bounds snapshots and replay payloads for growing sessions. It also publishes host-wide session updates and keeps rename, archive, restore, and permanent delete under OMP authority. T4 Code vendors \`@oh-my-pi/app-wire\` ${APP_WIRE_VERSION}.`, }, { kind: "note", - text: `The stock upstream OMP v${OMP_RUNTIME_VERSION} tag does not contain this appserver fix. It remains protocol-compatible, but a very large active session can disconnect while attaching.`, + text: `Official upstream OMP v${OMP_RUNTIME_VERSION} does not ship the \`appserver\` command, so it cannot host T4 Code. Use the public integration tag above. It builds from that repository like any other OMP checkout; T4 Code has no dependency on private home-directory files, an auth broker, or a custom Codex CLI fork.`, }, ], }; @@ -152,17 +154,35 @@ const firstRun: DocTopic = { const localSessions: DocTopic = { id: "local-sessions", title: "Local sessions", - lede: "Open projects, start sessions, and switch between them without losing your place.", + lede: "Open working folders, start sessions, and switch between them without losing your place.", blocks: [ { kind: "h2", id: "local-sessions-create", text: "Start a session" }, { kind: "p", - text: "Pick a project and start a session, with an optional title. The session runs on the OMP host; T4 Code streams everything it does into the transcript.", + text: "Pick a working folder and start a session, with an optional title. The session runs on the OMP host; T4 Code streams everything it does into the transcript.", }, { kind: "p", text: "New session references retain the project name OMP reports. The rail does not replace that name with an opaque project ID while the new session is attaching.", }, + { kind: "h2", id: "local-sessions-folders", text: "What a working folder means" }, + { + kind: "p", + text: "A heading in the left rail is the working directory reported by the sessions beneath it. It is not a separate T4 Code project record. A folder group disappears when it has no Current or Archived sessions to show.", + }, + { + kind: "note", + text: "This release does not independently alias, pin, reorder, or hide working-folder groups. Those controls need a server-owned workspace registry so desktop and phone agree; a browser-only preference would drift between clients.", + }, + { kind: "h2", id: "local-sessions-lifecycle", text: "Rename, archive, restore, or delete" }, + { + kind: "p", + text: "The rail has Current and Archived views. Rename changes a session title. Archive is reversible and keeps the transcript and artifacts. Restore returns the session to Current.", + }, + { + kind: "p", + text: "Permanent delete removes the session transcript and its artifact directory. T4 Code asks you to type the exact session title, and OMP refuses the operation if the session is busy or its revision changed during confirmation.", + }, { kind: "h2", id: "local-sessions-switching", text: "Switching stays instant" }, { kind: "p", @@ -229,7 +249,7 @@ const sessionControls: DocTopic = { { kind: "h2", id: "session-controls-model", text: "Model" }, { kind: "p", - text: "The primary picker follows the connected OMP profile's `Ctrl+P` cycle in exact order instead of exposing the full catalog. On narrow touch screens, the menu has a bounded vertical scroller. The profile used in the mobile test exposed six choices: Luna 5.6, Opus 4.6, Fable 5, GPT 5.6 Sol, Kimi K2.7, and Grok 4.5.", + text: "The primary picker follows the connected OMP profile's `Ctrl+P` cycle in exact order instead of exposing the full catalog. It shows the choices and labels reported by that host, so changing the OMP profile changes the picker without a T4 Code rebuild. On narrow touch screens, the menu has a bounded vertical scroller.", }, { kind: "p", @@ -412,7 +432,11 @@ const troubleshooting: DocTopic = { kind: "p", text: "If that fails, look at the logs: `~/.local/state/t4-code/appserver` on Linux, `~/Library/Logs/T4 Code/appserver` on macOS. If `omp` is installed somewhere unusual, point T4 Code at it with the `OMP_EXECUTABLE` environment variable.", }, - { kind: "h2", id: "troubleshooting-connection", text: "\u201cConnection Lost\u201d / \u201cNo Connection\u201d" }, + { + kind: "h2", + id: "troubleshooting-connection", + text: "\u201cConnection Lost\u201d / \u201cNo Connection\u201d", + }, { kind: "p", text: "The link to the app server (or the network) dropped. You can reconnect right away or keep working offline with what already streamed in. Remote hosts reconnect on their own; local ones restart with the service manager.", @@ -420,7 +444,7 @@ const troubleshooting: DocTopic = { { kind: "h2", id: "troubleshooting-large-session", text: "Session appears but never loads" }, { kind: "p", - text: `A large, actively growing transcript can exceed the stock OMP v${OMP_RUNTIME_VERSION} appserver's replay limit during attach. T4 Code v${RELEASE_VERSION} stops the resulting reconnect loop, but the client cannot repair a snapshot the host never delivered. Use the [verified OMP fix](${OMP_RUNTIME_FIX_URL}) or a later upstream build that contains the same bounded replay behavior.`, + text: `First confirm that \`omp appserver status --json\` succeeds. Official upstream OMP v${OMP_RUNTIME_VERSION} cannot answer that command and cannot host T4 Code. On older public appserver integration builds, a large, actively growing transcript can exceed the replay limit during attach. T4 Code v${RELEASE_VERSION} stops the resulting reconnect loop, but the client cannot repair a snapshot the host never delivered. Use the [verified OMP integration tag](${OMP_RUNTIME_URL}) or a later public or upstream build that includes appserver support and the same bounded replay behavior.`, }, { kind: "h2", id: "troubleshooting-declined", text: "\u201cThe host declined…\u201d" }, { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 0501c106..9d093cfd 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -6,10 +6,12 @@ export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; export const OMP_RUNTIME_VERSION = "16.4.8"; -export const OMP_RUNTIME_FIX_COMMIT = "f65bb37970d2186f04ec4b650eb0b53ec3b1337b"; -export const OMP_RUNTIME_FIX_URL = `https://github.com/lyc-aon/oh-my-pi/commit/${OMP_RUNTIME_FIX_COMMIT}`; -export const RELEASE_TAG = "v0.1.5"; -export const RELEASE_VERSION = "0.1.5"; +export const OMP_RUNTIME_COMMIT = "932bbaceb256f43eb3b2760341f2175803da4d07"; +export const OMP_RUNTIME_TAG = "t4code-16.4.8-appserver-4"; +export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; +export const APP_WIRE_VERSION = "0.5.2"; +export const RELEASE_TAG = "v0.1.6"; +export const RELEASE_VERSION = "0.1.6"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export type Platform = "linux" | "mac"; @@ -42,10 +44,10 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("linux", "deb", "x86_64", "T4-Code-0.1.5-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.5-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.5-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.5-mac-arm64.zip", "macOS .zip"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.6-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.6-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.6-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.6-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/src/site.css b/apps/site/src/site.css index d1219aa2..910a8694 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -38,8 +38,8 @@ --motion-ease: cubic-bezier(0, 0, 0.2, 1); --font-sans: "DM Sans Variable", -apple-system, "Segoe UI", system-ui, sans-serif; - --font-mono: "JetBrains Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, - monospace; + --font-mono: + "JetBrains Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; --topbar-height: 56px; --measure: 44rem; @@ -100,6 +100,7 @@ pre { background: var(--code-background); border: 1px solid var(--border); border-radius: 4px; + overflow-wrap: anywhere; padding: 0.1em 0.35em; } @@ -477,7 +478,6 @@ pre.code { .codeblock .copy-btn:hover { color: var(--foreground); } - .notice { border: 1px solid var(--border-strong); border-left: 3px solid var(--warning-foreground); diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 1acfef59..7b17097c 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,9 +1,13 @@ -// Release contract guard: exact v0.1.5 asset names and URLs, and the +// Release contract guard: exact v0.1.6 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { + APP_WIRE_VERSION, assetsFor, detectPlatform, + OMP_RUNTIME_COMMIT, + OMP_RUNTIME_TAG, + OMP_RUNTIME_URL, primaryAsset, RELEASE_ASSETS, RELEASE_TAG, @@ -12,27 +16,25 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the four contracted v0.1.5 filenames", () => { + it("carries the four contracted v0.1.6 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.5-linux-amd64.deb", - "T4-Code-0.1.5-linux-x86_64.AppImage", - "T4-Code-0.1.5-mac-arm64.dmg", - "T4-Code-0.1.5-mac-arm64.zip", + "T4-Code-0.1.6-linux-amd64.deb", + "T4-Code-0.1.6-linux-x86_64.AppImage", + "T4-Code-0.1.6-mac-arm64.dmg", + "T4-Code-0.1.6-mac-arm64.zip", ]); }); it("builds download URLs under the release tag", () => { for (const asset of RELEASE_ASSETS) { - expect(asset.url).toBe( - `${REPO_URL}/releases/download/${RELEASE_TAG}/${asset.filename}`, - ); + expect(asset.url).toBe(`${REPO_URL}/releases/download/${RELEASE_TAG}/${asset.filename}`); } }); it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.5"); - expect(RELEASE_VERSION).toBe("0.1.5"); + expect(RELEASE_TAG).toBe("v0.1.6"); + expect(RELEASE_VERSION).toBe("0.1.6"); }); it("splits assets by platform with correct architectures", () => { @@ -48,19 +50,26 @@ describe("release assets", () => { }); }); +describe("OMP integration contract", () => { + it("pins the verified runtime tag, commit, and app-wire package", () => { + expect(OMP_RUNTIME_TAG).toBe("t4code-16.4.8-appserver-4"); + expect(OMP_RUNTIME_COMMIT).toBe("932bbaceb256f43eb3b2760341f2175803da4d07"); + expect(OMP_RUNTIME_URL).toBe( + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.4.8-appserver-4", + ); + expect(APP_WIRE_VERSION).toBe("0.5.2"); + }); +}); + describe("detectPlatform", () => { it("detects macOS user agents", () => { expect( - detectPlatform( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", - ), + detectPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"), ).toBe("mac"); }); it("detects Linux user agents", () => { - expect(detectPlatform("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe( - "linux", - ); + expect(detectPlatform("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe("linux"); }); it("falls back to Linux for platforms without a build", () => { diff --git a/apps/web/package.json b/apps/web/package.json index e3d9987b..be6df5f8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index cacb4fe9..d2c1ceb0 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -26,16 +26,24 @@ export function AppShell() { const railCollapsed = useWorkspace((state) => state.railCollapsed); const railWidth = useWorkspace((state) => state.railWidth); const railOverlayOpen = useWorkspace((state) => state.railOverlayOpen); + const sessionListView = useWorkspace((state) => state.sessionListView); const projectExpandedById = useWorkspace((state) => state.projectExpandedById); const lastVisitedAtBySessionId = useWorkspace((state) => state.lastVisitedAtBySessionId); const [railPreviewWidth, setRailPreviewWidth] = useState(null); const [nowMs] = useState(() => Date.now()); const shellData = useShellData(); - const groups = useMemo( - () => buildProjectGroups(shellData, projectExpandedById, lastVisitedAtBySessionId), + const currentGroups = useMemo( + () => buildProjectGroups(shellData, projectExpandedById, lastVisitedAtBySessionId, "current"), [shellData, projectExpandedById, lastVisitedAtBySessionId], ); + const archivedGroups = useMemo( + () => buildProjectGroups(shellData, projectExpandedById, lastVisitedAtBySessionId, "archived"), + [shellData, projectExpandedById, lastVisitedAtBySessionId], + ); + const groups = sessionListView === "archived" ? archivedGroups : currentGroups; + const currentCount = shellData.sessions.filter((session) => session.archivedAt === undefined).length; + const archivedCount = shellData.sessions.length - currentCount; // Desktop mode: start the runtime once. StrictMode's doubled effect and // HMR remounts are safe — start is idempotent on a global singleton. @@ -79,6 +87,7 @@ export function AppShell() { getShellData(), state.projectExpandedById, state.lastVisitedAtBySessionId, + state.sessionListView, ), ); const sessionId = visible[action.index]; @@ -120,7 +129,7 @@ export function AppShell() { {railCollapsed ? (
{ const state = workspaceStore.getState(); state.setRailCollapsed(false); @@ -130,7 +139,13 @@ export function AppShell() {
) : (
- +
)} @@ -157,13 +172,16 @@ export function AppShell() { open={railOverlayOpen} >
- Projects and sessions + + + Working folders and sessions +
- +
)} - + ); } diff --git a/apps/web/src/components/CollapsedRail.tsx b/apps/web/src/components/CollapsedRail.tsx new file mode 100644 index 00000000..87cdbd81 --- /dev/null +++ b/apps/web/src/components/CollapsedRail.tsx @@ -0,0 +1,60 @@ +import { + cn, + IconButton, + STATUS_PILLS, + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@t4-code/ui"; + +import type { ProjectGroup } from "../lib/session-tree.ts"; + +/** 48px icon strip: one identity square per project, tooltip-labeled. */ +export function CollapsedRail({ + groups, + onExpand, +}: { + groups: readonly ProjectGroup[]; + onExpand: (projectId: string) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index 89b452ac..f1b5cf08 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -3,25 +3,63 @@ // Row/grouping interaction follows T3's sidebar; rendering is token-native. import { Badge, + Button, cn, + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, IconButton, Spinner, - STATUS_PILLS, StatusPill, Tooltip, TooltipPopup, TooltipTrigger, } from "@t4-code/ui"; +import { Popover } from "@base-ui/react/popover"; import { useNavigate } from "@tanstack/react-router"; -import { Cable, ChevronRight, Plus } from "lucide-react"; -import { type KeyboardEvent, useCallback, useState } from "react"; +import { + Archive, + Cable, + ChevronRight, + MoreHorizontal, + Pencil, + Plus, + RotateCcw, + Trash2, +} from "lucide-react"; +import { + type FormEvent, + type KeyboardEvent, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; -import type { WorkspaceSession } from "../lib/workspace-data.ts"; +import type { SessionListView, WorkspaceSession } from "../lib/workspace-data.ts"; import { formatRelativeTime, type ProjectGroup, type SessionRow } from "../lib/session-tree.ts"; import { createLiveSession } from "../features/session-runtime/live-create.ts"; +import { + archiveLiveSession, + deleteLiveSession, + managementCommandSupport, + renameLiveSession, + restoreLiveSession, + sessionCreateSupport, +} from "../features/session-runtime/session-management.ts"; +import { resolveSessionManagementNavigation } from "../features/session-runtime/session-navigation.ts"; import { desktopRuntime, useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; -import { resolveLiveProject } from "../platform/live-workspace.ts"; +import { + deriveWorkspaceData, + resolveLiveProject, + resolveLiveSession, +} from "../platform/live-workspace.ts"; import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; +import { SessionListTabs } from "./SessionListTabs.tsx"; function describeSessionState(session: WorkspaceSession): string { if (session.freshness === "offline") return "Offline"; @@ -29,76 +67,389 @@ function describeSessionState(session: WorkspaceSession): string { return session.status === null ? "Idle" : ""; } -function SessionRowButton({ +type SessionDialog = "rename" | "delete" | null; +type SessionAction = "rename" | "archive" | "restore" | "delete"; + +function SessionRowItem({ row, active, index, nowMs, + onAnnounce, }: { row: SessionRow; active: boolean; index: number; nowMs: number; + onAnnounce: (message: string) => void; }) { const navigate = useNavigate(); + const snapshot = useDesktopRuntimeSnapshot(); + const controller = desktopRuntime(); const { session } = row; const stateLabel = describeSessionState(session); const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "idle"); - return ( + const [menuOpen, setMenuOpen] = useState(false); + const [dialog, setDialog] = useState(null); + const [renameValue, setRenameValue] = useState(session.title); + const [deleteValue, setDeleteValue] = useState(""); + const [pending, setPending] = useState(null); + const pendingRef = useRef(null); + const [error, setError] = useState(null); + const address = snapshot === null ? null : resolveLiveSession(snapshot, session.id); + const archived = session.archivedAt !== undefined; + + const support = ( + command: "session.rename" | "session.archive" | "session.restore" | "session.delete", + ) => + snapshot === null || address === null + ? { supported: false, reason: "Connect to this host to manage the session" } + : managementCommandSupport(snapshot, address, command); + const renameSupport = support("session.rename"); + const archiveSupport = support("session.archive"); + const restoreSupport = support("session.restore"); + const deleteSupport = support("session.delete"); + const workingReason = + archiveSupport.reason === "Stop the session before archiving or deleting it" || + deleteSupport.reason === "Stop the session before archiving or deleting it" + ? "Stop the session before archiving or deleting it" + : null; + + const runAction = useCallback( + async (action: SessionAction) => { + if (pendingRef.current !== null || controller === null || address === null) return; + pendingRef.current = action; + setPending(action); + setError(null); + try { + if (action === "rename") await renameLiveSession(controller, address, renameValue); + else if (action === "archive") await archiveLiveSession(controller, address); + else if (action === "restore") await restoreLiveSession(controller, address); + else await deleteLiveSession(controller, address); + const verb = + action === "rename" + ? "renamed" + : action === "archive" + ? "archived" + : action === "restore" + ? "restored" + : "permanently deleted"; + onAnnounce(`${session.title} ${verb}.`); + setMenuOpen(false); + setDialog(null); + if (action !== "rename") { + const navigation = resolveSessionManagementNavigation( + action, + session, + deriveWorkspaceData(controller.getSnapshot()).sessions, + active, + ); + workspaceStore.getState().setSessionListView(navigation.view); + if (navigation.navigate) { + workspaceStore.getState().setRailOverlayOpen(false); + if (navigation.destinationSessionId === null) void navigate({ to: "/" }); + else + void navigate({ + params: { sessionId: navigation.destinationSessionId }, + to: "/sessions/$sessionId", + }); + } + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Session action failed."); + } finally { + pendingRef.current = null; + setPending(null); + } + }, + [active, address, controller, navigate, onAnnounce, renameValue, session.id, session.title], + ); + + const menuItem = ( + action: SessionAction, + label: string, + icon: ReactNode, + available: { readonly supported: boolean; readonly reason: string | null }, + ) => ( ); + + return ( +
+
+ + {controller !== null && address !== null && ( + + + {pending === null ? ( + + + + + + {session.title} + + {!archived && + menuItem( + "rename", + "Rename", + + + + + )} +
+ {error !== null && ( +

+ {error} +

+ )} + + (open ? undefined : setDialog(null))} + open={dialog === "rename"} + > + +
{ + event.preventDefault(); + void runAction("rename"); + }} + > + + Rename session + + Use a short name you will recognize in this working folder. + + + {error !== null && ( +

+ {error} +

+ )} +
+ + + } + > + Cancel + + + +
+
+
+ + (open ? undefined : setDialog(null))} + open={dialog === "delete"} + > + + + Permanently delete “{session.title}”? + + This permanently deletes the session, transcript, artifacts, and generated output. It + cannot be undone. + + + {error !== null && ( +

+ {error} +

+ )} +
+ + + } + > + Keep session + + + +
+
+
+ ); } -function ProjectHeaderRow({ group }: { group: ProjectGroup }) { +function ProjectHeaderRow({ group, allowCreate }: { group: ProjectGroup; allowCreate: boolean }) { const navigate = useNavigate(); const snapshot = useDesktopRuntimeSnapshot(); const controller = desktopRuntime(); @@ -106,18 +457,26 @@ function ProjectHeaderRow({ group }: { group: ProjectGroup }) { const [error, setError] = useState(null); const address = snapshot !== null ? resolveLiveProject(snapshot, group.project.id) : null; - const connected = address !== null && snapshot !== null && snapshot.connections.get(address.targetId) === "connected"; - const host = address !== null && snapshot !== null ? snapshot.hosts.get(address.hostId) : undefined; - const canCreate = connected && host !== undefined && host.grantedCapabilities.includes("sessions.manage"); + const createSupport = + address !== null && snapshot !== null + ? sessionCreateSupport(snapshot, address) + : { supported: false, reason: "Connect to this host to create a session" }; + const canCreate = + allowCreate && + createSupport.supported && + controller !== null && + address !== null && + !pending; const handleCreate = useCallback( async (event: React.MouseEvent) => { event.stopPropagation(); - if (!canCreate || controller === null || address === null || pending) return; + if (!canCreate || controller === null || address === null) return; setPending(true); setError(null); try { const result = await createLiveSession(controller, address); + workspaceStore.getState().setRailOverlayOpen(false); void navigate({ params: { sessionId: result.viewId }, to: "/sessions/$sessionId" }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Session creation failed."); @@ -165,23 +524,33 @@ function ProjectHeaderRow({ group }: { group: ProjectGroup }) { )} {group.sessions.length} - {canCreate && ( + {allowCreate && ( - {pending ? : )} @@ -210,26 +579,55 @@ function handleRailKeyDown(event: KeyboardEvent) { event.preventDefault(); } -export function Rail({ groups, nowMs }: { groups: readonly ProjectGroup[]; nowMs: number }) { +export function Rail({ + groups, + nowMs, + view, + currentCount, + archivedCount, +}: { + groups: readonly ProjectGroup[]; + nowMs: number; + view: SessionListView; + currentCount: number; + archivedCount: number; +}) { const activeSessionId = useWorkspace((state) => state.activeSessionId); + const [announcement, setAnnouncement] = useState(""); let rowIndex = 0; return (