diff --git a/apps/desktop/src/doctor.ts b/apps/desktop/src/doctor.ts index ac97fd4..658b4dc 100644 --- a/apps/desktop/src/doctor.ts +++ b/apps/desktop/src/doctor.ts @@ -15,7 +15,9 @@ import { discoverNativeOmpProfiles } from "./local-profiles.ts"; import { createSafeServiceEnvironment, discoverOmpExecutable, + inspectPathOmpCompatibility, OmpAppserverCompatibilityError, + type PathOmpCompatibility, probeOmpAppserver, } from "./service.ts"; @@ -46,6 +48,7 @@ export interface DoctorRuntime { readonly sourceContract: () => Promise; readonly pnpmVersion: () => Promise; readonly discoverOmp: () => Promise; + readonly inspectPathOmp: () => Promise; readonly probeOmp: (executable: string) => Promise; readonly profileCount: () => Promise; readonly inspectTailnet: () => Promise; @@ -193,6 +196,7 @@ export function createDoctorRuntime(): DoctorRuntime { sourceContract: () => readSourceContract(), pnpmVersion: installedPnpmVersion, discoverOmp: () => discoverOmpExecutable(), + inspectPathOmp: () => inspectPathOmpCompatibility(), probeOmp: (executable) => probeOmpAppserver(executable), profileCount: async () => (await discoverNativeOmpProfiles()).length, inspectTailnet, @@ -324,6 +328,45 @@ export async function collectDoctorReport( ); } + const pathOmp = await runtime.inspectPathOmp().catch(() => "unavailable" as const); + const pathOmpChecks: Record = { + compatible: check( + "terminal-omp", + "OMP commands", + "pass", + "Every omp command found on PATH provides the appserver contract T4 requires.", + ), + incompatible: check( + "terminal-omp", + "OMP commands", + "warning", + "The omp commands found on PATH are too old for T4 live ownership signals. T4 can still open saved history, but a running task may look idle or update in chunks.", + `Install the verified OMP integration (${contract.ompTag}) in every shell or app launch path: ${contract.ompUrl}`, + ), + missing: check( + "terminal-omp", + "OMP commands", + "warning", + "No omp command was found on PATH.", + `Install the verified OMP integration (${contract.ompTag}) before starting tasks from another shell or app: ${contract.ompUrl}`, + ), + mixed: check( + "terminal-omp", + "OMP commands", + "warning", + "Different omp commands are installed, and they do not all pass T4's appserver check. A task can therefore look live in one app but idle or delayed in another.", + `Update or remove stale OMP copies so every shell and app uses the verified integration (${contract.ompTag}): ${contract.ompUrl}`, + ), + unavailable: check( + "terminal-omp", + "OMP commands", + "warning", + "The omp commands found on PATH could not be verified safely.", + `Check that every shell and app uses the verified OMP integration (${contract.ompTag}): ${contract.ompUrl}`, + ), + }; + checks.push(pathOmpChecks[pathOmp]); + if (executable !== undefined) { const running = await runtime.probeOmp(executable).catch(() => false); checks.push( diff --git a/apps/desktop/src/service.ts b/apps/desktop/src/service.ts index 2fdac07..bbca636 100644 --- a/apps/desktop/src/service.ts +++ b/apps/desktop/src/service.ts @@ -38,7 +38,9 @@ export function createSafeServiceEnvironment( return safeEnvironment; } -const APP_SERVER_PROBE_TIMEOUT_MS = 1_500; +// Cold OMP startup can exceed 1.5 seconds on macOS. A shorter deadline can +// reject the verified runtime before it returns a healthy status response. +const APP_SERVER_PROBE_TIMEOUT_MS = 3_000; const APP_SERVER_PROBE_MAX_OUTPUT_BYTES = 16 * 1024; export class OmpAppserverCompatibilityError extends Error { @@ -66,6 +68,13 @@ export interface OmpAppserverProbeOptions readonly profileId?: string; } +export type PathOmpCompatibility = + | "compatible" + | "incompatible" + | "missing" + | "mixed" + | "unavailable"; + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -188,6 +197,51 @@ export async function discoverOmpExecutable( return undefined; } +/** + * Check every `omp` command on PATH. T4 may have its own compatible bundled + * runtime while another shell or app launch path selects an older build, + * which makes cross-app activity look idle or arrive in chunks. + */ +export async function inspectPathOmpCompatibility( + options: Omit = {}, +): Promise { + const environment = options.environment ?? process.env; + const runner = options.runner ?? new NodeProcessRunner(); + const timeoutMs = options.timeoutMs ?? APP_SERVER_PROBE_TIMEOUT_MS; + const maxOutputBytes = options.maxOutputBytes ?? APP_SERVER_PROBE_MAX_OUTPUT_BYTES; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10_000) return "unavailable"; + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024) + return "unavailable"; + + const entries = (environment.PATH ?? "") + .split(":") + .filter((entry) => entry.startsWith("/") && !entry.includes("\0")) + .slice(0, 64); + const seen = new Set(); + let compatible = 0; + let incompatible = 0; + let unavailable = 0; + for (const entry of entries) { + const candidate = join(entry, "omp"); + if (seen.has(candidate)) continue; + seen.add(candidate); + try { + await access(candidate, fsConstants.X_OK); + } catch { + continue; + } + const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes); + if (state === "running" || state === "stopped") compatible += 1; + else if (state === "incompatible") incompatible += 1; + else unavailable += 1; + } + if (compatible > 0 && (incompatible > 0 || unavailable > 0)) return "mixed"; + if (compatible > 0) return "compatible"; + if (incompatible > 0) return "incompatible"; + if (unavailable > 0) return "unavailable"; + return "missing"; +} + export async function probeOmpAppserver( executable: string, options: OmpAppserverProbeOptions = {}, diff --git a/apps/desktop/test/desktop-lifecycle.test.ts b/apps/desktop/test/desktop-lifecycle.test.ts index 715a0a5..2078d6b 100644 --- a/apps/desktop/test/desktop-lifecycle.test.ts +++ b/apps/desktop/test/desktop-lifecycle.test.ts @@ -6,6 +6,7 @@ import type { ProcessRunner, ProcessSpec } from "@t4-code/remote"; import { createSafeServiceEnvironment, discoverOmpExecutable, + inspectPathOmpCompatibility, NodeServiceRunner, OmpAppserverCompatibilityError, probeOmpAppserver, @@ -91,6 +92,52 @@ describe("desktop lifecycle boundaries", () => { expect(error.message.includes("requires `omp appserver status --json`")).toBe(true); expect(calls).toBe(1); }); + it("detects mixed PATH candidates instead of blessing only the first compatible one", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); + const oldDir = join(root, "old"); + const newDir = join(root, "new"); + await mkdir(oldDir); + await mkdir(newDir); + await writeFile(join(oldDir, "omp"), ""); + await writeFile(join(newDir, "omp"), ""); + await chmod(join(oldDir, "omp"), 0o755); + await chmod(join(newDir, "omp"), 0o755); + let calls = 0; + const runner: ProcessRunner = { + spawn: async (spec) => { + calls += 1; + if (spec.command === join(newDir, "omp")) { + return { + kill: () => {}, + result: Promise.resolve({ + exitCode: 0, + signal: null, + stdout: JSON.stringify({ state: "stopped", reason: "unreachable" }), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }; + } + return { + kill: () => {}, + result: Promise.resolve({ + exitCode: 2, + signal: null, + stdout: "", + stderr: "Error: unknown flag: --json", + stdoutTruncated: false, + stderrTruncated: false, + }), + }; + }, + }; + + expect( + await inspectPathOmpCompatibility({ environment: { PATH: `${oldDir}:${newDir}` }, runner }), + ).toBe("mixed"); + expect(calls).toBe(2); + }); it("scopes named appserver probes without inheriting provider credentials", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); const executable = join(root, "omp"); diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index 308de7f..bb79f05 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -29,6 +29,7 @@ function runtime(overrides: Partial = {}): DoctorRuntime { sourceContract: async () => contract, pnpmVersion: async () => "11.10.0", discoverOmp: async () => "/opt/omp/bin/omp", + inspectPathOmp: async () => "compatible", probeOmp: async () => true, profileCount: async () => 3, inspectTailnet: async () => "ready", @@ -56,6 +57,7 @@ describe("T4 setup doctor", () => { ["node", "pass"], ["pnpm", "pass"], ["omp", "pass"], + ["terminal-omp", "pass"], ["appserver", "pass"], ["profiles", "pass"], ["tailscale", "pass"], @@ -63,6 +65,18 @@ describe("T4 setup doctor", () => { expect(formatDoctorReport(report)).toContain("Required setup checks passed."); }); + it("warns when shells and apps can resolve different OMP builds", async () => { + const report = await collectDoctorReport( + runtime({ inspectPathOmp: async () => "mixed" }), + ); + const terminalOmp = report.checks.find((item) => item.id === "terminal-omp"); + + expect(report.ok).toBe(true); + expect(terminalOmp).toMatchObject({ status: "warning", label: "OMP commands" }); + expect(terminalOmp?.detail).toContain("look live in one app but idle or delayed in another"); + expect(terminalOmp?.action).toContain(contract.ompTag); + }); + it("explains incompatible tools without exposing executable paths or raw errors", async () => { const report = await collectDoctorReport( runtime({ diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index b28eb32..ad85349 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -95,13 +95,16 @@ import { import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { SessionListTabs } from "./SessionListTabs.tsx"; -function describeSessionState(session: WorkspaceSession): string { +export function describeSessionState(session: WorkspaceSession): string { if (session.freshness === "offline") return "Offline"; if (session.freshness === "cached") return "Cached"; // Owner kind is never proven across the wire, so labels stay generic — // and only a confirmed live lock reads "Active elsewhere". if (session.control !== undefined) return presentSessionControlKind(session.control).railLabel; - return session.status === null ? "Idle" : ""; + if (session.status !== null) return ""; + if (session.lifecycle === "idle") return "Idle"; + if (session.lifecycle === "closed") return "Stopped"; + return "Status unknown"; } type SessionDialog = "rename" | "terminate" | "delete" | null; @@ -138,7 +141,7 @@ function SessionRowItem({ const { session } = row; const pinned = useWorkspace((state) => state.pinnedSessionIds[session.id] === true); const stateLabel = describeSessionState(session); - const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "idle"); + const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "Status unknown"); const [menuOpen, setMenuOpen] = useState(false); const [dialog, setDialog] = useState(null); const [renameValue, setRenameValue] = useState(session.title); diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index dca56c4..ec5ac5f 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -47,7 +47,12 @@ import { type ExportContent, type ExportMeta, } from "../features/transcript/export.ts"; -import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx"; +import { + FreshnessBadge, + SessionLifecycleBadge, + SessionMain, + SessionOwnershipBadge, +} from "../features/transcript/SessionMain.tsx"; import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts"; import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; @@ -406,6 +411,7 @@ export function SessionScreen({ )} + {previewCount > 0 && ( diff --git a/apps/web/src/features/session-runtime/session-observer.test.ts b/apps/web/src/features/session-runtime/session-observer.test.ts index f9e941b..e42cbf1 100644 --- a/apps/web/src/features/session-runtime/session-observer.test.ts +++ b/apps/web/src/features/session-runtime/session-observer.test.ts @@ -167,7 +167,7 @@ describe("presentSessionControl", () => { } const live = presentSessionControl({ mode: "observer", lockStatus: "live", transcript: "live" }); expect(live.bannerDetail).toBe( - "Finished lines land here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.", + "Following saved output, not a token-by-token stream. Finished steps appear here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.", ); }); diff --git a/apps/web/src/features/session-runtime/session-observer.ts b/apps/web/src/features/session-runtime/session-observer.ts index 55bb5dd..71bc80b 100644 --- a/apps/web/src/features/session-runtime/session-observer.ts +++ b/apps/web/src/features/session-runtime/session-observer.ts @@ -151,7 +151,7 @@ export function presentSessionControl(state: SessionControlState): SessionContro return { railLabel: "Active elsewhere", bannerTitle: "Active in another app", - bannerDetail: `Finished lines land here as the other app saves them. ${TRANSFER_HINT}`, + bannerDetail: `Following saved output, not a token-by-token stream. Finished steps appear here as the other app saves them. ${TRANSFER_HINT}`, bannerBusy: false, composerReason: OBSERVER_COMPOSER_REASON, cancelReason: "Only the app running this session can stop it.", diff --git a/apps/web/src/features/transcript/SessionMain.tsx b/apps/web/src/features/transcript/SessionMain.tsx index 666234c..7508423 100644 --- a/apps/web/src/features/transcript/SessionMain.tsx +++ b/apps/web/src/features/transcript/SessionMain.tsx @@ -5,7 +5,7 @@ // actions leave through typed SessionIntents. The shell's outer scroll // container stays inert — this surface owns its own virtualized scroller. import { useNavigate } from "@tanstack/react-router"; -import { Badge, cn, Tooltip, TooltipPopup, TooltipTrigger, useReducedMotion } from "@t4-code/ui"; +import { Badge, cn, StatusPill, Tooltip, TooltipPopup, TooltipTrigger, useReducedMotion } from "@t4-code/ui"; import { useCallback, useEffect, @@ -23,6 +23,7 @@ import { resolveLiveSession } from "../../platform/live-workspace.ts"; import { Composer } from "../composer/Composer.tsx"; import { flattenFileIndex } from "../composer/file-refs.ts"; import { getInspectorStore, type FileChildren } from "../panes/inspector-store.ts"; +import { useNowTick } from "../panes/hooks.ts"; import { ApprovalPanel, AskPanel, @@ -48,6 +49,7 @@ import { computeStableRows, deriveAttention, deriveTranscriptRows, + formatElapsed, initialStableRowsState, shouldShowAttention, type StableRowsState, @@ -97,6 +99,32 @@ export function FreshnessBadge({ session }: { readonly session: WorkspaceSession return null; } +/** Plain lifecycle badge for sessions that have no richer live status pill. */ +export function SessionLifecycleBadge({ session }: { readonly session: WorkspaceSession }) { + if (session.freshness !== "live" || session.status !== null || session.control !== undefined) + return null; + + const label = + session.lifecycle === "idle" + ? "Idle" + : session.lifecycle === "closed" + ? "Stopped" + : "Status unknown"; + const detail = + label === "Status unknown" + ? "T4 has saved history for this task, but the runtime did not report whether it is running, idle, or stopped." + : label === "Stopped" + ? "The runtime reports that this task has stopped." + : "The runtime reports that this task is waiting and has no work in progress."; + + return ( + + {label}} /> + {detail} + + ); +} + /** Subheader ownership badge; mirrors FreshnessBadge, which wins when set. */ export function SessionOwnershipBadge({ session }: { readonly session: WorkspaceSession }) { if (session.freshness !== "live" || session.control === undefined) return null; @@ -150,6 +178,56 @@ function activityLabel(activity: SessionActivity): string { return ""; } +/** + * A quiet, continuously moving confirmation that this task is genuinely + * running here. The separate announcer below owns screen-reader updates so + * this visual timer can tick without speaking every second. + */ +export function SessionActivityBanner({ + activity, + nowMs, + startedAt, +}: { + readonly activity: SessionActivity; + readonly nowMs: number; + readonly startedAt: string | null; +}) { + if (activity === null) return null; + + return ( + + ); +} + +function SessionActivityElapsed({ + fromIso, + nowMs, +}: { + readonly fromIso: string; + readonly nowMs: number; +}) { + const tickMs = useNowTick(); + const baselineRef = useRef({ nowMs, tickMs }); + if (baselineRef.current.nowMs !== nowMs) baselineRef.current = { nowMs, tickMs }; + const elapsedNowMs = baselineRef.current.nowMs + tickMs - baselineRef.current.tickMs; + return {formatElapsed(fromIso, elapsedNowMs)}; +} + /** * One stable live region owns session activity announcements. Transcript rows * remain visual-only so the surrounding role=log cannot announce the same @@ -498,7 +576,11 @@ export function SessionMain({ onOpenHostHealth, session, exportRowsRef }: Sessio projection.entries, ); const sessionActivity: SessionActivity = - snapshot.link === "live" && !catchingUp && snapshot.sessionActive + !archived && + sessionControl === null && + snapshot.link === "live" && + !catchingUp && + snapshot.sessionActive ? projection.contextMaintenance === null ? "working" : "compacting" @@ -534,6 +616,11 @@ export function SessionMain({ onOpenHostHealth, session, exportRowsRef }: Sessio Catching up — refreshing this transcript from a snapshot )} + {controlPresentation !== null && sessionControl !== null && ( { }); expect(deriveWorkspaceData(controller.getSnapshot()).sessions[0]).toMatchObject({ freshness: "live", + lifecycle: "active", status: "working", }); }); @@ -2862,6 +2863,7 @@ describe("workspace projection safety", () => { }); expect(deriveWorkspaceData(controller.getSnapshot()).sessions[0]).toMatchObject({ + lifecycle: "idle", status: "working", latestTurnCompletedAt: null, }); diff --git a/apps/web/test/observer-ownership-region.test.tsx b/apps/web/test/observer-ownership-region.test.tsx index 2e68203..09af32f 100644 --- a/apps/web/test/observer-ownership-region.test.tsx +++ b/apps/web/test/observer-ownership-region.test.tsx @@ -53,7 +53,7 @@ describe("observer ownership region under freshness churn", () => { expect(markup).toContain('data-session-control-banner="observer"'); expect(markup).toContain("Active in another app"); expect(markup).toContain( - "Finished lines land here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.", + "Following saved output, not a token-by-token stream. Finished steps appear here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.", ); } }); diff --git a/apps/web/test/session-state-presentation.test.tsx b/apps/web/test/session-state-presentation.test.tsx new file mode 100644 index 0000000..6cfbe26 --- /dev/null +++ b/apps/web/test/session-state-presentation.test.tsx @@ -0,0 +1,79 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { describeSessionState } from "../src/components/Rail.tsx"; +import { + SessionActivityBanner, + SessionLifecycleBadge, +} from "../src/features/transcript/SessionMain.tsx"; +import type { WorkspaceSession } from "../src/lib/workspace-data.ts"; + +const BASE_SESSION: WorkspaceSession = { + id: "session-a", + projectId: "project-a", + title: "Session", + model: "model", + status: null, + freshness: "live", + pendingApprovals: 0, + latestTurnCompletedAt: null, + createdAt: "2026-07-20T10:00:00Z", + updatedAt: "2026-07-20T10:00:00Z", + lastActivity: "", +}; + +describe("truthful session state presentation", () => { + it("keeps idle, stopped, and missing lifecycle signals distinct", () => { + expect(describeSessionState({ ...BASE_SESSION, lifecycle: "idle" })).toBe("Idle"); + expect(describeSessionState({ ...BASE_SESSION, lifecycle: "closed" })).toBe("Stopped"); + expect(describeSessionState(BASE_SESSION)).toBe("Status unknown"); + }); + + it("lets freshness and confirmed ownership override lifecycle copy", () => { + expect(describeSessionState({ ...BASE_SESSION, freshness: "cached", lifecycle: "idle" })).toBe( + "Cached", + ); + expect(describeSessionState({ ...BASE_SESSION, control: "observer", lifecycle: "idle" })).toBe( + "Active elsewhere", + ); + }); + + it("shows the same explicit lifecycle in the task header", () => { + const idle = renderToStaticMarkup( + , + ); + const stopped = renderToStaticMarkup( + , + ); + const unknown = renderToStaticMarkup(); + + expect(idle).toContain("Idle"); + expect(stopped).toContain("Stopped"); + expect(unknown).toContain("Status unknown"); + }); + + it("renders a moving visual heartbeat only while work is confirmed", () => { + const working = renderToStaticMarkup( + , + ); + expect(working).toContain('data-session-activity-banner="working"'); + expect(working).toContain('data-status="working"'); + expect(working).toContain("animate-ping"); + expect(working).toContain("Working"); + expect( + renderToStaticMarkup(), + ).toBe(""); + }); + + it("starts the elapsed label from the runtime clock instead of the wall clock", () => { + const working = renderToStaticMarkup( + , + ); + + expect(working).toContain("5s"); + }); +});