From 8be55406a8a1c1d3b84889aa0df87087fd8446b8 Mon Sep 17 00:00:00 2001 From: lycaon Date: Mon, 13 Jul 2026 00:26:04 -0600 Subject: [PATCH 1/4] fix: make runtime and tailnet remote fully usable --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 43 ++ apps/desktop/src/target-manager.ts | 4 +- apps/desktop/test/target-manager.test.ts | 62 +- apps/web/index.html | 5 +- apps/web/src/app.css | 34 +- apps/web/src/components/AppShell.tsx | 9 +- apps/web/src/components/HomePane.tsx | 8 +- apps/web/src/components/Rail.tsx | 4 +- apps/web/src/components/SessionScreen.tsx | 117 +++- apps/web/src/components/Titlebar.tsx | 35 +- apps/web/src/components/rail-toggle.ts | 23 + apps/web/src/features/composer/Composer.tsx | 243 +++----- .../features/composer/ComposerControls.tsx | 83 ++- .../composer/ComposerRuntimeOptions.tsx | 138 +++++ .../src/features/composer/ContextMeter.tsx | 2 +- .../composer/MobileComposerActions.tsx | 94 +++ apps/web/src/features/composer/panels.tsx | 17 +- apps/web/src/features/panes/ActivityPane.tsx | 53 +- apps/web/src/features/panes/activity-log.ts | 23 +- .../features/session-runtime/live-runtime.ts | 29 +- .../session-event-vocabulary.ts | 143 +++++ apps/web/src/features/transcript/Markdown.tsx | 5 +- .../src/features/transcript/SessionMain.tsx | 15 +- .../features/transcript/TranscriptRows.tsx | 10 +- .../transcript/TranscriptTimeline.tsx | 2 +- .../web/src/features/transcript/projection.ts | 153 +++-- apps/web/src/platform/browser-shell-port.ts | 3 +- apps/web/src/router.tsx | 6 +- apps/web/test/browser-platform.test.ts | 25 +- apps/web/test/live-composer.test.ts | 1 + .../web/test/mobile-composer-actions.test.tsx | 88 +++ apps/web/test/mobile-touch-targets.test.tsx | 122 ++++ apps/web/test/panes-activity.test.ts | 44 ++ apps/web/test/rail-toggle.test.ts | 33 + apps/web/test/transcript-projection.test.ts | 138 ++++- docs/TAILNET_REMOTE.md | 161 +++++ e2e/fixture-process.ts | 49 ++ e2e/remote-app.spec.ts | 433 +++++++++++++ package.json | 9 +- packages/client/src/desktop-runtime.ts | 10 +- packages/client/src/index.ts | 1 + packages/client/src/omp-client-response.ts | 84 +++ packages/client/src/omp-client-runtime.ts | 43 +- packages/client/src/projection.ts | 42 +- packages/client/test/client.test.ts | 145 +++++ packages/client/test/desktop-runtime.test.ts | 25 + .../client/test/fixture-integration.test.ts | 15 +- packages/client/test/projection.test.ts | 72 ++- packages/fixture-server/src/engine.ts | 221 ++++++- packages/fixture-server/test/engine.test.ts | 138 ++++- packages/ui/src/primitives/sheet.tsx | 2 +- playwright.config.ts | 24 + pnpm-lock.yaml | 45 ++ provenance/t3code/imports/f0-20260711.json | 2 +- .../t3code/imports/f1-shell-20260711.json | 2 +- .../imports/f2-transcript-20260711.json | 6 +- scripts/live-tailnet-e2e.mjs | 188 ++++++ scripts/tailnet-gateway.mjs | 440 +++++++++++++ scripts/tailnet-gateway.test.mjs | 187 ++++++ scripts/tailnet-service.mjs | 583 ++++++++++++++++++ scripts/tailnet-service.test.mjs | 156 +++++ 62 files changed, 4483 insertions(+), 420 deletions(-) create mode 100644 apps/web/src/components/rail-toggle.ts create mode 100644 apps/web/src/features/composer/ComposerRuntimeOptions.tsx create mode 100644 apps/web/src/features/composer/MobileComposerActions.tsx create mode 100644 apps/web/src/features/session-runtime/session-event-vocabulary.ts create mode 100644 apps/web/test/mobile-composer-actions.test.tsx create mode 100644 apps/web/test/mobile-touch-targets.test.tsx create mode 100644 apps/web/test/rail-toggle.test.ts create mode 100644 docs/TAILNET_REMOTE.md create mode 100644 e2e/fixture-process.ts create mode 100644 e2e/remote-app.spec.ts create mode 100644 packages/client/src/omp-client-response.ts create mode 100644 playwright.config.ts create mode 100644 scripts/live-tailnet-e2e.mjs create mode 100644 scripts/tailnet-gateway.mjs create mode 100644 scripts/tailnet-gateway.test.mjs create mode 100644 scripts/tailnet-service.mjs create mode 100644 scripts/tailnet-service.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efbc246c..18790a59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install browser for end-to-end tests + run: pnpm exec playwright install --with-deps chromium + - name: Check source and types run: pnpm check @@ -43,5 +46,8 @@ jobs: - name: Build all workspaces run: pnpm build + - name: Run built-app end-to-end tests + run: pnpm test:e2e + - name: Check packaging contract run: pnpm test:packaging diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074028ec..c8475dec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,49 @@ env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out release tag + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install browser for end-to-end tests + run: pnpm exec playwright install --with-deps chromium + + - name: Check source and types + run: pnpm check + + - name: Run workspace tests + run: pnpm test + + - name: Run built-app end-to-end tests + run: pnpm test:e2e + + - name: Build all workspaces + run: pnpm build + + - name: Check packaging contract + run: pnpm test:packaging + build-linux: + needs: verify runs-on: ubuntu-24.04 timeout-minutes: 35 steps: @@ -64,6 +106,7 @@ jobs: retention-days: 7 build-macos: + needs: verify runs-on: macos-15 timeout-minutes: 40 steps: diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index 6ed24e19..5586a0ab 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -1,4 +1,4 @@ -import { createOmpClient, OmpClientError, type CommandIntent, type CursorStore, type OmpClient, type PublicServerFrame } from "@t4-code/client"; +import { createOmpClient, isConfirmationDecisionConsumed, OmpClientError, type CommandIntent, type CursorStore, type OmpClient, type PublicServerFrame } from "@t4-code/client"; import type { CommandResult, ConnectionStateEvent, RuntimeErrorEvent } from "@t4-code/protocol/desktop-ipc"; import type { ConfirmRequest, ConfirmResult, TerminalCloseRequest, TerminalInputRequest, TerminalResizeRequest, TerminalResult } from "@t4-code/protocol/desktop-ipc"; import { ADDITIVE_FEATURES, DEVICE_CAPABILITIES, type DeviceCapability } from "@t4-code/protocol"; @@ -210,7 +210,7 @@ export class DesktopTargetManager { requestId: String(result.requestId), confirmationId: request.confirmationId, commandId: request.commandId, - accepted: result.ok, + accepted: isConfirmationDecisionConsumed(result), }; } catch (error) { if (this.generations.get(targetId) !== generation && error instanceof OmpClientError && error.code === "closed") diff --git a/apps/desktop/test/target-manager.test.ts b/apps/desktop/test/target-manager.test.ts index 32434e3f..cd0bebab 100644 --- a/apps/desktop/test/target-manager.test.ts +++ b/apps/desktop/test/target-manager.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { DESKTOP_IPC_CHANNELS, decodeDesktopInvokeRequest } from "@t4-code/protocol/desktop-ipc"; import type { CursorStore, OmpTransport } from "@t4-code/client"; -import { DEVICE_CAPABILITIES, hostId, type WelcomeFrame } from "@t4-code/protocol"; +import { commandId, confirmationId, DEVICE_CAPABILITIES, hostId, sessionId, type WelcomeFrame } from "@t4-code/protocol"; import { validEvent } from "../src/ipc.ts"; import { DesktopTargetManager } from "../src/target-manager.ts"; import type { RemoteTargetRecord, RemoteTargetRegistry } from "../src/remote-runtime/registry.ts"; @@ -230,6 +230,66 @@ describe("desktop target manager boundaries", () => { expect((await rejected).accepted).toBe(false); await runtime.close(); }); + it("treats a host-acknowledged denial as a consumed confirmation decision", async () => { + const transports: Transport[] = []; + const runtime = new DesktopTargetManager({ + cursorStore: new Store(), + transportFactory: () => { + const next = new Transport(); + transports.push(next); + return next as never; + }, + events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, + }); + await runtime.connect(); + const transport = transports[0]; + if (transport === undefined) throw new Error("transport was not created"); + + const heldCommand = runtime.command({ + hostId: "host-fixture", + sessionId: "session-a", + command: "session.cancel", + args: {}, + }); + const command = await transport.waitForSent(1); + transport.receive({ + v: V, + type: "confirmation", + confirmationId: "confirm-deny", + commandId: command.commandId, + hostId: "host-fixture", + sessionId: "session-a", + commandHash: "sha256:fixture", + revision: "revision-a", + expiresAt: "2999-01-01T00:00:00.000Z", + summary: "session.cancel", + }); + const confirmed = runtime.confirm({ + targetId: "local", + confirmationId: confirmationId("confirm-deny"), + commandId: commandId(String(command.commandId)), + hostId: hostId("host-fixture"), + sessionId: sessionId("session-a"), + decision: "deny", + }); + await transport.waitForSent(2); + transport.receive({ + v: V, + type: "response", + requestId: command.requestId, + commandId: command.commandId, + hostId: "host-fixture", + sessionId: "session-a", + command: "session.cancel", + ok: false, + error: { code: "confirmation_denied", message: "command was denied" }, + }); + const [commandResult, confirmationResult] = await Promise.all([heldCommand, confirmed]); + expect(commandResult.accepted).toBe(false); + expect(confirmationResult.accepted).toBe(true); + expect(confirmationResult.requestId).toBe(command.requestId); + await runtime.close(); + }); it("reads remote auth on each Hello without retaining credential material", async () => { const transports: Transport[] = []; const registry = new Registry(); diff --git a/apps/web/index.html b/apps/web/index.html index 9dc82023..1a552732 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,10 @@ - + T4 Code diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 9fbca5e3..747af106 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -19,6 +19,8 @@ html, body { height: 100%; + height: 100dvh; + max-width: 100%; margin: 0; padding: 0; overscroll-behavior: none; @@ -31,6 +33,8 @@ #root { height: 100%; + height: 100dvh; + max-width: 100%; width: 100%; overflow: hidden; } @@ -58,14 +62,38 @@ .workspace-topbar { display: flex; - height: var(--workspace-topbar-height); - min-height: var(--workspace-topbar-height); + height: calc(var(--workspace-topbar-height) + env(safe-area-inset-top)); + min-height: calc(var(--workspace-topbar-height) + env(safe-area-inset-top)); + padding-top: env(safe-area-inset-top); flex-shrink: 0; align-items: center; } .surface-subheader { - @apply flex h-10 min-h-10 shrink-0 items-center border-border/60 border-b bg-background; + @apply flex h-13 min-h-13 shrink-0 items-center border-border/60 border-b bg-background; + } + + @media (min-width: 40rem) { + .surface-subheader { + @apply h-10 min-h-10; + } + } + + /* Right-pane and rail sheets can contain dense desktop-native controls. + On phones their visible chassis stays compact, but the actual interactive + box must remain thumb-safe rather than relying on a pseudo hit target. */ + @media (max-width: 39.999rem) { + [data-slot="sheet-popup"] + :where( + button, + [role="button"], + input:not([type="checkbox"]):not([type="radio"]), + select, + textarea + ) { + min-width: 2.75rem; + min-height: 2.75rem; + } } /* macOS traffic lights live at the top-left of the frameless window. diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index abbe0208..856845bc 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "./CommandPalette.tsx"; import { CollapsedRail, Rail } from "./Rail.tsx"; import { ResizeHandle } from "./ResizeHandle.tsx"; import { Titlebar } from "./Titlebar.tsx"; +import { resolveRailTogglePresentation } from "./rail-toggle.ts"; export function AppShell() { const navigate = useNavigate(); @@ -92,15 +93,21 @@ export function AppShell() { // Rail collapse/expand animates width via .rail-dock; the center column // reflows with it and keeps its own focus and scroll. const effectiveRailWidth = railPreviewWidth ?? railWidth; + const railToggle = resolveRailTogglePresentation({ + overlaid: railOverlaid, + overlayOpen: railOverlayOpen, + collapsed: railCollapsed, + }); return ( -
+
{ const state = workspaceStore.getState(); if (railOverlaid) state.setRailOverlayOpen(!state.railOverlayOpen); else state.setRailCollapsed(!state.railCollapsed); }} + railToggle={railToggle} />
{!railOverlaid && ( diff --git a/apps/web/src/components/HomePane.tsx b/apps/web/src/components/HomePane.tsx index cc131b47..acb970be 100644 --- a/apps/web/src/components/HomePane.tsx +++ b/apps/web/src/components/HomePane.tsx @@ -71,6 +71,7 @@ function DesktopHomePane({ const targetsApi = useMemo(() => createTargetsStore(controller, {}), [controller]); const actionsState = useSyncExternalStore(actions.subscribe, actions.getState); const state = snapshot === null ? null : deriveDesktopHomeState(snapshot); + const browserDirect = shell.serviceInspect === undefined; const needsInspection = state !== null && state.kind === "service"; // Entering the service state reads the real service once; every action @@ -118,10 +119,11 @@ function DesktopHomePane({ - No sessions yet + {browserDirect ? "Choose a live session" : "No sessions yet"} - This machine is connected. Sessions you start here or from a paired device appear in - the list on the left the moment they exist. + {browserDirect + ? "This Tailnet connection is live. Choose a session from the list on the left to inspect it." + : "This machine is connected. Sessions you start here or from a paired device appear in the list on the left the moment they exist."} diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index 32d2b907..89b452ac 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -133,7 +133,7 @@ function ProjectHeaderRow({ group }: { group: ProjectGroup }) {
+ <> +
+ {PANE_FAMILY_META.map((meta) => { + const active = view.paneOpen && view.paneFamily === meta.id; + const Icon = meta.icon; + return ( + + workspaceStore.getState().togglePaneFamily(sessionId, meta.id)} + size="icon-sm" + > + + ); + })} +
+ + + ); +} + +function MobileFamilyMenu({ sessionId, view }: { sessionId: string; view: SessionViewState }) { + const [open, setOpen] = useState(false); + const activeMeta = PANE_FAMILY_META.find((entry) => entry.id === view.paneFamily); + const TriggerIcon = view.paneOpen && activeMeta !== undefined ? activeMeta.icon : PanelRight; + return ( + + + + + + + + Session panels + +
    + {PANE_FAMILY_META.map((meta) => { + const active = view.paneOpen && view.paneFamily === meta.id; + const Icon = meta.icon; + return ( +
  • + +
  • + ); + })} +
+
+
+
+
); } @@ -98,7 +153,7 @@ export function SessionScreen({ return (
-
+
{session.title} {project.name} @@ -106,13 +161,18 @@ export function SessionScreen({ {session.model} - {session.status !== null && } + {session.status !== null && ( + <> + + + + )} -