From b38dcd0807a7c77bdc26d4822a58fed56bfe6a79 Mon Sep 17 00:00:00 2001 From: pseudo <27822378+pseudoseed@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:53:02 -0600 Subject: [PATCH] fix(ci): main's lint and tests pass again Main's CI failed on every push. Lint found 54 errors in fork code, all from upstream lint rules that arrived with the 0.0.76 sync. 6 tests in scripts/mobile-native-client.test.ts failed on CI's x64 runners, because the fork's test reached upstream's Apple-silicon check without pinning the host. - Delete the comments the comment rule rejects, rather than trim them. - Delete issueProviderRequirement, which nothing ever called. - State the reason on effects that hold native, DOM or server resources. Direct push reads environments through useEffectEvent, so its effects depend only on the signals that re-run them. - Drop IssuesPanel's manual memos; the React Compiler memoizes it. - Move 2 browser tests onto the shared render helper. - Replace the em dash in the attention notification title. - Pin arm64 in the native-client test, as it already pins darwin. - sync-upstream now runs the whole-repo lint and requires CI to pass. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/sync-upstream/SKILL.md | 20 ++- .../agent-awareness/AgentWidgetSync.tsx | 2 + .../agent-awareness/DirectPushCoordinator.tsx | 73 ++++---- .../widgets/pseudocode/ProjectIconSync.tsx | 1 + .../src/diagnostics/EventLoopStallMonitor.ts | 7 - .../src/provider/providerCredentialHome.ts | 17 -- .../src/terminal/shell-integration/index.ts | 11 -- apps/server/src/usage/aiUsageApi.ts | 13 +- apps/server/src/usage/aiUsageDashboard.ts | 17 -- apps/server/src/wsKeepAlive.ts | 12 -- .../components/AgentAttentionCoordinator.tsx | 4 +- .../RightPanelTabs.browser.test.tsx | 167 ++++++++---------- .../web/src/components/issues/IssuesPanel.tsx | 44 ++--- .../components/issues/issuesPanel.logic.ts | 32 ---- .../SourceClearCooldown.browser.test.tsx | 57 +++--- apps/web/src/hooks/useCopyOnSelect.ts | 10 +- apps/web/src/lib/copyOnSelect.ts | 9 - apps/web/src/terminal/theme/ansiPalette.ts | 14 -- packages/client-runtime/src/contextWindow.ts | 1 - packages/client-runtime/src/state/issues.ts | 8 - packages/contracts/src/issue.ts | 28 --- scripts/mobile-native-client.test.ts | 1 + 22 files changed, 183 insertions(+), 365 deletions(-) diff --git a/.agents/skills/sync-upstream/SKILL.md b/.agents/skills/sync-upstream/SKILL.md index 45f0f9d9eeb85..7d9e1e7626a63 100644 --- a/.agents/skills/sync-upstream/SKILL.md +++ b/.agents/skills/sync-upstream/SKILL.md @@ -38,8 +38,10 @@ git merge --no-ff upstream/main Resolve every conflict so both sides survive: upstream's change and the fork's behaviour or text. When upstream rewrote code the fork had changed, carry the fork's intent into upstream's new shape, including its conventions (for example `Effect.fn` in place of plain arrow functions). Resolving a -whole file to one side is how fork changes go missing. Done when -`git diff --name-only --diff-filter=U` prints nothing and the merge is committed. +whole file to one side is how fork changes go missing. Chris wants no comments in code unless +absolutely needed, so a fork comment that conflicts or that upstream's comment lint rejects is +deleted, not trimmed. Done when `git diff --name-only --diff-filter=U` prints nothing and the merge +is committed. ### 3. Prove fork changes survived @@ -68,15 +70,19 @@ it separately: `chore(release): bump PseudoCode to `. ```sh vp run check:changed +vp run lint .agents/skills/sync-upstream/scripts/run-suites.sh ``` +`vp run lint` is CI's whole-repo lint. A sync brings upstream's new lint rules, and they fail fork +files the merge never touched, which `check:changed` does not read. + `run-suites.sh` runs every package's tests one at a time, the one place in this repo where the full suites run locally: Chris needs a sync green before it ships. Fix each failure at its cause. When you believe a failure belongs to this machine rather than the code, prove it: run the same test in a clean upstream worktree (`git worktree add ../t3-upstream-check upstream/main`, then `vp i` there). If it fails there too, report it with that evidence as a failure, never as a pass, and -remove the worktree. Done when both commands pass, or every remaining failure carries that proof. +remove the worktree. Done when all 3 commands pass, or every remaining failure carries that proof. ### 6. Check the app live @@ -89,7 +95,13 @@ merge touched. Done when each surface was driven and what you saw is written dow Push the branch to `origin` and open a PR into the fork's `main`. The body names the upstream commit merged, the fork changes restored, each DROPPED line accepted and why, the test counts, and what the live check covered. Chris merges it with a **merge commit**; a squash would orphan the -commits the release tag points at. Done when the PR is open. Stop there until Chris merges. +commits the release tag points at. + +The PR's CI must pass. This Mac is arm64 and CI runs on x64 Linux, so some failures only show up +there; fix them at their cause like any other. A job GitHub cancels with "not acquired by Runner" +never got a macOS runner: rerun it with +`gh run rerun --failed --repo pseudoseed/t3code_dev`. Done when the PR is open and every CI +job passes. Stop there until Chris merges. ### 8. Release from merged main diff --git a/apps/mobile/src/features/agent-awareness/AgentWidgetSync.tsx b/apps/mobile/src/features/agent-awareness/AgentWidgetSync.tsx index 3c6574a7d22db..1c4e1b53908bd 100644 --- a/apps/mobile/src/features/agent-awareness/AgentWidgetSync.tsx +++ b/apps/mobile/src/features/agent-awareness/AgentWidgetSync.tsx @@ -65,11 +65,13 @@ function IosAgentWidgetSync() { // entry is dated from the last write, and a quiet, healthy app must not // let the widget claim its updates are late. const [refreshTick, setRefreshTick] = useState(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs the timer that forces a periodic widget write useEffect(() => { const timer = setInterval(() => setRefreshTick((tick) => tick + 1), WIDGET_REFRESH_MS); return () => clearInterval(timer); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the snapshot to the native widget's shared storage useEffect(() => { // Register the gallery layout even before a server snapshot is available. if (canPublish && previousContent.current === contentKey && refreshTick === lastTick.current) diff --git a/apps/mobile/src/features/agent-awareness/DirectPushCoordinator.tsx b/apps/mobile/src/features/agent-awareness/DirectPushCoordinator.tsx index ef452391b8b99..5d5fda3e4b183 100644 --- a/apps/mobile/src/features/agent-awareness/DirectPushCoordinator.tsx +++ b/apps/mobile/src/features/agent-awareness/DirectPushCoordinator.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom, AsyncResult } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; import { AppState, Platform } from "react-native"; import * as Notifications from "expo-notifications"; import type { DirectPushDelivery, EnvironmentId } from "@t3tools/contracts"; @@ -62,6 +62,7 @@ function Coordinator() { ]), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- listens for app foregrounding and push token rotation useEffect(() => { const app = AppState.addEventListener("change", (state) => { if (state === "active") setRefresh((n) => n + 1); @@ -74,6 +75,7 @@ function Coordinator() { tokens.remove(); }; }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- installs the native notification handler while direct push is on useEffect(() => { if (!enabled) return; Notifications.setNotificationHandler({ @@ -87,6 +89,7 @@ function Coordinator() { return () => Notifications.setNotificationHandler(null); }, [enabled]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps native Live Activities and the server's preference in step with the direct push setting useEffect(() => { if (!chosen) return; if (enabled) endLocalLiveActivities("Could not close the Cloud Connect Live Activity."); @@ -97,11 +100,7 @@ function Coordinator() { }); }, [enabled, chosen]); - // Registration follows the socket, not the foreground: a reconnect while - // backgrounded (or a token rotated while suspended) must still reach the - // server, or the widget's push path stays dead until the next open. - useEffect(() => { - if (!chosen || enabled) return; + const disableConnected = useEffectEvent(() => { for (const environment of environments) { if (environment.connection.phase === "connected") { void disableDirectPush(environment.environmentId) @@ -111,43 +110,49 @@ function Coordinator() { ); } } - }, [connectionKey, chosen, enabled, refresh]); - + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- turns direct push off on each server as it connects, backgrounded or not useEffect(() => { - if (!loaded || !enabled) return; + if (!chosen || enabled) return; + disableConnected(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- connection changes and app foregrounding repeat the disable + }, [connectionKey, chosen, enabled, refresh]); - let cancelled = false; - // Coalesce bursts from shell hydration and provider lifecycle changes. - const timer = setTimeout(() => { - for (const environment of environments) { - if (environment.connection.phase !== "connected") continue; - const id = environment.environmentId; - setStatus(id, "Registering…"); - void syncDirectPush(id, liveEnabled) - .then((status) => { - if (cancelled) return; - setDelivery(id, status?.lastDelivery); + const registerConnected = useEffectEvent((isCancelled: () => boolean) => { + for (const environment of environments) { + if (environment.connection.phase !== "connected") continue; + const id = environment.environmentId; + setStatus(id, "Registering…"); + void syncDirectPush(id, liveEnabled) + .then((status) => { + if (isCancelled()) return; + setDelivery(id, status?.lastDelivery); + setStatus( + id, + status?.registered + ? "Registered with Apple notification delivery" + : "APNs setup needed on this server", + ); + }) + .catch((error: unknown) => { + if (!isCancelled()) setStatus( id, - status?.registered - ? "Registered with Apple notification delivery" - : "APNs setup needed on this server", + error instanceof Error ? error.message : "Registration failed. Reconnect to retry.", ); - }) - .catch((error: unknown) => { - if (!cancelled) - setStatus( - id, - error instanceof Error ? error.message : "Registration failed. Reconnect to retry.", - ); - }); - } - }, 500); + }); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers each connected server for direct push, coalescing bursts of shell updates + useEffect(() => { + if (!loaded || !enabled) return; + let cancelled = false; + const timer = setTimeout(() => registerConnected(() => cancelled), 500); return () => { cancelled = true; clearTimeout(timer); }; - // The signatures exclude incidental object changes from shell updates. + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- connection, thread activity, setting and foreground changes repeat the registration }, [connectionKey, activityKey, enabled, liveEnabled, loaded, refresh]); return null; } diff --git a/apps/mobile/src/widgets/pseudocode/ProjectIconSync.tsx b/apps/mobile/src/widgets/pseudocode/ProjectIconSync.tsx index 96e301f347ac8..752577939fa34 100644 --- a/apps/mobile/src/widgets/pseudocode/ProjectIconSync.tsx +++ b/apps/mobile/src/widgets/pseudocode/ProjectIconSync.tsx @@ -21,6 +21,7 @@ export function ProjectIconSync({ }), ); const key = `${project.environmentId}:${project.id}`; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- downscales the project favicon for the native widget and aborts the download on cleanup useEffect(() => { const controller = new AbortController(); if (url && !isProjectFaviconFallbackUrl(url)) diff --git a/apps/server/src/diagnostics/EventLoopStallMonitor.ts b/apps/server/src/diagnostics/EventLoopStallMonitor.ts index 2610eea713b39..4d169b9c4a72c 100644 --- a/apps/server/src/diagnostics/EventLoopStallMonitor.ts +++ b/apps/server/src/diagnostics/EventLoopStallMonitor.ts @@ -3,13 +3,6 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -/** - * Notices when the process stopped being scheduled. A one-second timer that - * fires many seconds late means the host suspended, napped, or starved this - * process, which from a remote client looks like a server that ignores - * requests. Each stall becomes a span so the trace log carries proof of when - * and for how long, next to the connection churn it caused. - */ interface EventLoopStallMonitorOptions { readonly intervalMs: number; readonly thresholdMs: number; diff --git a/apps/server/src/provider/providerCredentialHome.ts b/apps/server/src/provider/providerCredentialHome.ts index 5edabd299e034..afd52c596c9f5 100644 --- a/apps/server/src/provider/providerCredentialHome.ts +++ b/apps/server/src/provider/providerCredentialHome.ts @@ -1,20 +1,3 @@ -/** - * Per-instance credential directories. - * - * A provider CLI keeps one signed-in account per credential directory, so two - * instances of the same provider need two directories or the second sign-in - * overwrites the first. Users should not have to know that, so PseudoCode - * provisions a directory for any instance that does not already have one. - * - * The rule has one deliberate exception. The instance whose id equals its - * driver kind is the one migrated from the single-instance world; it keeps - * pointing at the provider's own default home (`~/.claude`, `~/.codex`) so an - * existing sign-in, and everything else the user has in that directory, keeps - * working untouched. Every additional instance is new by definition and gets - * its own directory. - * - * @module provider/providerCredentialHome - */ import { defaultInstanceIdForDriver, type ProviderDriverKind, diff --git a/apps/server/src/terminal/shell-integration/index.ts b/apps/server/src/terminal/shell-integration/index.ts index fb9b7710255f3..ebef1d59d1629 100644 --- a/apps/server/src/terminal/shell-integration/index.ts +++ b/apps/server/src/terminal/shell-integration/index.ts @@ -1,14 +1,3 @@ -/** - * Wires OSC 133 shell integration into terminal spawns. - * - * The terminal renders whatever the shell sends, and a default zsh or bash - * prompt sends no styling at all. We give stock zsh prompts a compact colored - * layout and preserve custom prompts. OSC 133 markers identify prompt, input, - * and output boundaries for libghostty-vt. - * - * Supported for zsh and bash on POSIX platforms. Every other shell spawns - * untouched. - */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; diff --git a/apps/server/src/usage/aiUsageApi.ts b/apps/server/src/usage/aiUsageApi.ts index e26a3c978c076..5951872a470b3 100644 --- a/apps/server/src/usage/aiUsageApi.ts +++ b/apps/server/src/usage/aiUsageApi.ts @@ -1,14 +1,3 @@ -/** - * aiUsageApi — the `aiusage` source kind: a local AI usage dashboard that - * polls each vendor's own limits endpoint. It exposes the same - * `readAccounts` / `consume` pair as {@link ./cliproxyApi.ts}, so - * UsageLimitSources only has to pick an adapter by `config.kind`. - * - * A dashboard runs on this machine and authenticates nothing, so there is no - * management key and no per-account credential handling here. - * - * @module usage/aiUsageApi - */ import { UsageLimitSourceError, type ProviderConsumeResetCreditResult, @@ -51,7 +40,7 @@ const failureDetail = (error: { readonly _tag: string }): string => { } }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const makeAiUsageApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; diff --git a/apps/server/src/usage/aiUsageDashboard.ts b/apps/server/src/usage/aiUsageDashboard.ts index d3e396a9a6008..177426be10b32 100644 --- a/apps/server/src/usage/aiUsageDashboard.ts +++ b/apps/server/src/usage/aiUsageDashboard.ts @@ -1,20 +1,3 @@ -/** - * Maps an AI usage dashboard's `/api/usage` response onto the usage limit - * windows the Limits view renders, one row per account it polls. - * - * The dashboard groups accounts by company and hands each one a flat list of - * meters it has already normalised from the vendor's own limits endpoint: - * `{ id, group, name, percent, resets_at }`, where `group` is exactly the - * window kinds this contract uses. So the mapping is mostly a rename, plus a - * driver lookup for the icon and Codex's banked reset credits. - * - * Unlike a CLIProxyAPI hub, the dashboard reports accounts this environment - * is not signed into. An account it could not read keeps its row with the - * failure on it rather than disappearing, matching how the dashboard's own - * cards behave. - * - * @module usage/aiUsageDashboard - */ import { ProviderDriverKind, type ServerProviderUsageLimits, diff --git a/apps/server/src/wsKeepAlive.ts b/apps/server/src/wsKeepAlive.ts index dcb4384e493fa..64914a42ad236 100644 --- a/apps/server/src/wsKeepAlive.ts +++ b/apps/server/src/wsKeepAlive.ts @@ -6,18 +6,6 @@ export interface WebSocketKeepAliveOptions { readonly missesBeforeClose: number; } -/** - * Server-side liveness for the Node `ws` path. A phone that gets suspended, - * changes networks, or loses its NAT mapping never sends a close frame; - * without this the connection, its subscriptions, and its session's - * "connected" count outlive the device indefinitely. `ws` answers pings at - * the protocol level without waking the client's JavaScript, so a healthy - * but idle client costs one frame per interval and a dead one is reaped after - * `missesBeforeClose` silent intervals. Any inbound frame counts as life. - * - * Returned as a `WebSocket` subclass because that is the only per-connection - * hook `ws` exposes through `WebSocketServer` options. - */ export function makeKeepAliveWebSocket( WebSocketClass: typeof WsTypes.WebSocket, options: WebSocketKeepAliveOptions, diff --git a/apps/web/src/components/AgentAttentionCoordinator.tsx b/apps/web/src/components/AgentAttentionCoordinator.tsx index aa22dfb8232c8..bfc5ef0bd0f6d 100644 --- a/apps/web/src/components/AgentAttentionCoordinator.tsx +++ b/apps/web/src/components/AgentAttentionCoordinator.tsx @@ -37,7 +37,7 @@ function EnvironmentAttentionObserver({ environmentId }: { environmentId: Enviro const ref = scopeThreadRef(environmentId, request.threadId); try { const notification = showAgentAttentionNotification({ - title: `${APP_DISPLAY_NAME} — ${request.kind === "approval" ? "Approval needed" : "Input needed"}`, + title: `${APP_DISPLAY_NAME}: ${request.kind === "approval" ? "Approval needed" : "Input needed"}`, body: request.title, tag: `agent-attention:${scopedThreadKey(ref)}`, onClick: () => { @@ -53,6 +53,7 @@ function EnvironmentAttentionObserver({ environmentId }: { environmentId: Enviro return delivered; }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the environment shell atom and closes the system notifications it raised on cleanup useEffect(() => { const track = createAgentAttentionTracker(); const notifications = new Map(); @@ -103,6 +104,7 @@ function EnvironmentAttentionObserver({ environmentId }: { environmentId: Enviro export function AgentAttentionCoordinator() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const soundEnabled = useClientSettings((settings) => settings.agentAttentionSound); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds window gesture listeners that unlock browser audio while the sound setting is on useEffect(() => { if (!soundEnabled || isElectron) return; const removeListeners = () => { diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx index bc894dcf27381..fee3d325b8935 100644 --- a/apps/web/src/components/RightPanelTabs.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx @@ -1,8 +1,8 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../test/render"; const setAudioMuted = vi.hoisted(() => vi.fn(async () => undefined)); @@ -80,109 +80,95 @@ function overlay( }; } -let root: Root; -let container: HTMLDivElement; const noop = () => undefined; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { setAudioMuted.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} -async function renderTabs( +function tabs( first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon, audio?: { audible?: boolean; audioMuted?: boolean }, previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`, ) { - await act(() => - root.render( - ({ - terminalFocus: false, - terminalOpen: false, - previewFocus: false, - previewOpen: false, - isWeb: true, - isDesktop: false, - })} - surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} - environmentId={null} - activeSurfaceId={previewSurface.id} - pendingSurfaceIds={new Set()} - previewSessions={sessions} - desktopByTabId={{ - "tab-1": overlay(first, audio), - ...(second ? { "tab-2": overlay(second) } : {}), - }} - {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} - terminalLabelsById={new Map()} - onActivate={noop} - onCloseSurface={noop} - onCloseOtherSurfaces={noop} - onCloseSurfacesToRight={noop} - onCloseAllSurfaces={noop} - onCopyFilePath={noop} - onAddBrowser={noop} - onAddBrowserInProfile={noop} - onAddTerminal={noop} - onAddPullRequest={noop} - onAddPullRequests={noop} - onAddDiff={noop} - onAddFiles={noop} - onAddDevice={noop} - browserAvailable - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} - > -
content
-
, - ), + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} + environmentId={null} + activeSurfaceId={previewSurface.id} + pendingSurfaceIds={new Set()} + previewSessions={sessions} + desktopByTabId={{ + "tab-1": overlay(first, audio), + ...(second ? { "tab-2": overlay(second) } : {}), + }} + {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDiff={noop} + onAddFiles={noop} + onAddDevice={noop} + browserAvailable + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + > +
content
+
); } -const imageSources = () => [...container.querySelectorAll("img")].map((image) => image.src); -const button = (name: string) => - [...container.querySelectorAll("button")].find((element) => element.ariaLabel === name); +const imageSources = (screen: Screen) => + [...screen.container.querySelectorAll("img")].map((image) => image.src); +const button = (screen: Screen, name: string) => + [...screen.container.querySelectorAll("button")].find((element) => element.ariaLabel === name); describe("preview tab favicon", () => { it("prefers a live capture and never asks a public provider about a private hostname", async () => { - await renderTabs(favicon(PIXEL_A, "http://24x.xf.local/")); - expect(imageSources()).toEqual([PIXEL_A]); + await using screen = await render(tabs(favicon(PIXEL_A, "http://24x.xf.local/"))); + expect(imageSources(screen)).toEqual([PIXEL_A]); - await renderTabs(null); - expect(imageSources()).toEqual([]); + await screen.rerender(tabs(null)); + expect(imageSources(screen)).toEqual([]); }); it("keeps route-specific captures isolated between live tabs on one origin", async () => { - await renderTabs( - favicon(PIXEL_A, "http://24x.xf.local/"), - favicon(PIXEL_B, "http://24x.xf.local/admin"), + await using screen = await render( + tabs(favicon(PIXEL_A, "http://24x.xf.local/"), favicon(PIXEL_B, "http://24x.xf.local/admin")), ); - expect(imageSources()).toEqual([PIXEL_A, PIXEL_B]); + expect(imageSources(screen)).toEqual([PIXEL_A, PIXEL_B]); }); it("hides a capture while the server session still describes another origin", async () => { - await renderTabs(favicon(PIXEL_A, "https://example.com/")); - expect(imageSources()).not.toContain(PIXEL_A); + await using screen = await render(tabs(favicon(PIXEL_A, "https://example.com/"))); + expect(imageSources(screen)).not.toContain(PIXEL_A); }); }); @@ -191,22 +177,23 @@ describe("preview tab audio toggle", () => { { audible: false, audioMuted: false }, { audible: false, audioMuted: true }, ])("is absent for a silent tab (muted=$audioMuted)", async (audio) => { - await renderTabs(null, undefined, audio); - expect(button("Mute Local site")).toBeUndefined(); - expect(button("Unmute Local site")).toBeUndefined(); + await using screen = await render(tabs(null, undefined, audio)); + expect(button(screen, "Mute Local site")).toBeUndefined(); + expect(button(screen, "Unmute Local site")).toBeUndefined(); }); it.each([ { audioMuted: false, label: "Mute Local site", nextMuted: true }, { audioMuted: true, label: "Unmute Local site", nextMuted: false }, ])("$label addresses the desktop by runtime tab id", async ({ audioMuted, label, nextMuted }) => { - await renderTabs(null, undefined, { audible: true, audioMuted }); - await act(() => button(label)!.click()); + setup(); + await using screen = await render(tabs(null, undefined, { audible: true, audioMuted })); + await act(() => button(screen, label)!.click()); expect(setAudioMuted).toHaveBeenCalledExactlyOnceWith("runtime:tab-1", nextMuted); }); it("hides the toggle when no runtime tab id can be resolved", async () => { - await renderTabs(null, undefined, { audible: true }, null); - expect(button("Mute Local site")).toBeUndefined(); + await using screen = await render(tabs(null, undefined, { audible: true }, null)); + expect(button(screen, "Mute Local site")).toBeUndefined(); }); }); diff --git a/apps/web/src/components/issues/IssuesPanel.tsx b/apps/web/src/components/issues/IssuesPanel.tsx index 1fff43c3ebe01..81d1b01add3bf 100644 --- a/apps/web/src/components/issues/IssuesPanel.tsx +++ b/apps/web/src/components/issues/IssuesPanel.tsx @@ -8,7 +8,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { CircleCheck, CircleDot, Loader2, Plus, RefreshCw, Search, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -88,17 +88,14 @@ export function IssuesPanel(props: IssuesPanelProps) { const result = listQuery.data; const searchesOnHost = result?.provider?.searchesOnHost ?? false; - const entries = useMemo(() => { - const rows = sortIssues(result?.entries ?? [], sort); - // A host that searched for us has already matched bodies this cannot see. - return searchesOnHost ? rows : narrowIssues(rows, submittedSearch); - }, [result?.entries, searchesOnHost, sort, submittedSearch]); + const sortedEntries = sortIssues(result?.entries ?? [], sort); + const entries = searchesOnHost ? sortedEntries : narrowIssues(sortedEntries, submittedSearch); - const submitSearch = useCallback(() => setSubmittedSearch(search), [search]); - const clearSearch = useCallback(() => { + const submitSearch = () => setSubmittedSearch(search); + const clearSearch = () => { setSearch(""); setSubmittedSearch(""); - }, []); + }; if (openNumber !== null) { const repository = entries.find((entry) => entry.number === openNumber)?.repository; @@ -332,14 +329,11 @@ function IssueDetailView(props: { readonly onBack: () => void; readonly onChanged: () => void; }) { - const reference = useMemo( - () => ({ - projectId: props.projectId, - repository: props.repository, - number: props.number, - }), - [props.projectId, props.repository, props.number], - ); + const reference = { + projectId: props.projectId, + repository: props.repository, + number: props.number, + }; const detailQuery = useEnvironmentQuery( issueEnvironment.detail({ environmentId: props.environmentId, input: reference }), ); @@ -354,7 +348,7 @@ function IssueDetailView(props: { const detail = detailQuery.data; - const postComment = useCallback(async () => { + const postComment = async () => { const body = draft.trim(); if (!canComment || body.length === 0 || pending !== null) return; setPending("comment"); @@ -365,9 +359,9 @@ function IssueDetailView(props: { setPending(null); // The draft is kept on failure: the reader's words are the one thing worth not losing. if (result._tag === "Success") setDraft(""); - }, [canComment, comment, draft, pending, props.environmentId, reference]); + }; - const toggleState = useCallback(async () => { + const toggleState = async () => { if (!canSetState || !detail || pending !== null) return; setPending("state"); const result = await setState({ @@ -376,13 +370,13 @@ function IssueDetailView(props: { }); setPending(null); if (result._tag === "Success") props.onChanged(); - }, [canSetState, detail, pending, props, reference, setState]); + }; /** * Opens a thread on this project, on a branch named for the issue, with the issue already in * its composer. Nothing is sent: the reader reads what is there and decides. */ - const startWork = useCallback(async () => { + const startWork = async () => { if (!detail || starting) return; setStarting(true); const opened = await newThread(scopeProjectRef(props.environmentId, props.projectId), { @@ -408,7 +402,7 @@ function IssueDetailView(props: { title: "Thread opened", description: "The issue is in the composer. Read it over, then send.", }); - }, [detail, newThread, props.environmentId, props.projectId, starting]); + }; return (
@@ -536,7 +530,7 @@ function NewIssueView(props: { const create = useAtomCommand(issueEnvironment.create, { reportFailure: true }); const [pending, setPending] = useState(false); - const submit = useCallback(async () => { + const submit = async () => { const trimmed = title.trim(); if (!canCreate || trimmed.length === 0 || pending) return; setPending(true); @@ -551,7 +545,7 @@ function NewIssueView(props: { setPending(false); // The draft stays on screen where it failed, so nothing typed is thrown away. if (result._tag === "Success") props.onCreated(result.value.number); - }, [body, canCreate, create, pending, props, title]); + }; return (
diff --git a/apps/web/src/components/issues/issuesPanel.logic.ts b/apps/web/src/components/issues/issuesPanel.logic.ts index c11b28d5bde71..893e9731e83db 100644 --- a/apps/web/src/components/issues/issuesPanel.logic.ts +++ b/apps/web/src/components/issues/issuesPanel.logic.ts @@ -18,14 +18,6 @@ export const ISSUE_SORTS = [ export type IssueSort = (typeof ISSUE_SORTS)[number]["value"]; -/** - * Rows as the panel shows them. - * - * Newest change first by default, which is the order somebody scanning a list expects and the - * order every host reports its own "recently updated" in. The other two answer the two - * questions a list of issues actually gets asked: what has been sitting here longest, and what - * is being argued about. - */ export function sortIssues( entries: ReadonlyArray, sort: IssueSort = "recent", @@ -79,14 +71,6 @@ export function labelColor(color: string | null): string | undefined { return /^[0-9a-f]{3}$|^[0-9a-f]{6}$/iu.test(hex) ? `#${hex}` : undefined; } -/** - * An issue as a block of context for the composer. - * - * The whole thing rather than a link: an agent handed a URL has to go and fetch it, and it - * cannot — the panel read it with the reader's own credentials. Comments are included because - * the argument in them is usually the part worth acting on, and the description alone often is - * not the current state of the question. - */ export function issueContextForComposer(issue: { readonly number: number; readonly title: string; @@ -129,14 +113,6 @@ export function appendIssueContext(prompt: string, context: string): string { return existing.length === 0 ? `${context}\n\n` : `${existing}\n\n${context}\n\n`; } -/** - * A branch for work on an issue: the number first so it sorts and greps by issue, then as much - * of the title as reads. The number alone would be unreadable in a branch list, and the title - * alone would collide the moment two issues are worded alike. - * - * A title that sanitises to nothing becomes the shared helper's own "update", which says less - * than the number already has, so it is dropped. - */ export function issueBranchName(issue: { readonly number: number; readonly title: string; @@ -149,14 +125,6 @@ export function issueBranchName(issue: { const ISSUE_BRANCH_PATTERN = /^issue-(\d+)(?:-|$)/u; -/** - * The issue a branch was made for, or null. - * - * Read from the branch rather than recorded anywhere: "Start work" names branches - * `issue-{number}-{slug}`, so the link already exists in the one place git will keep it. That - * costs nothing to store and survives a thread being renamed or moved, and it is honest about - * its limit — a branch somebody named themselves says nothing, and gets nothing. - */ export function issueNumberFromBranch(branch: string | null | undefined): number | null { if (typeof branch !== "string") return null; // A worktree branch may carry a namespace, so the last segment is the one that was named here. diff --git a/apps/web/src/components/usage/SourceClearCooldown.browser.test.tsx b/apps/web/src/components/usage/SourceClearCooldown.browser.test.tsx index 61a0fa06d1ccb..b8b01d93c0c10 100644 --- a/apps/web/src/components/usage/SourceClearCooldown.browser.test.tsx +++ b/apps/web/src/components/usage/SourceClearCooldown.browser.test.tsx @@ -1,7 +1,7 @@ import { EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../../test/render"; const state = vi.hoisted(() => ({ clear: vi.fn() })); vi.mock("../../state/server", () => ({ serverEnvironment: { clearSourceCooldown: null } })); @@ -11,62 +11,51 @@ import { SourceClearCooldown } from "./SourceClearCooldown"; const environmentId = EnvironmentId.make("remote"); const input = { sourceId: UsageLimitSourceId.make("hub"), accountId: "claude.json" }; -let root: Root; -let container: HTMLDivElement; -const button = () => container.querySelector("button")!; -const status = () => container.querySelector('[role="status"]')?.textContent; -async function renderControl() { - await act(() => root.render()); -} -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +const button = (screen: Screen) => screen.container.querySelector("button")!; +const status = (screen: Screen) => screen.container.querySelector('[role="status"]')?.textContent; +function setup() { state.clear.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} +const control = () => ; it("prevents duplicate clears while pending and reports success without a credit confirmation", async () => { + setup(); let complete!: () => void; const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { complete = () => resolve({ _tag: "Success", value: undefined }); }); state.clear.mockReturnValue(pending); - await renderControl(); + await using screen = await render(control()); await act(() => { - button().click(); - button().click(); + button(screen).click(); + button(screen).click(); }); - expect(button().disabled).toBe(true); - expect(button().textContent).toBe("Clearing…"); + expect(button(screen).disabled).toBe(true); + expect(button(screen).textContent).toBe("Clearing…"); expect(state.clear).toHaveBeenCalledExactlyOnceWith({ environmentId, input }); await act(async () => { complete(); }); - expect(button().disabled).toBe(false); - expect(status()).toBe("Proxy cooldown cleared. Retry your request."); + expect(button(screen).disabled).toBe(false); + expect(status(screen)).toBe("Proxy cooldown cleared. Retry your request."); }); it("shows the hub's failure and allows a fresh retry", async () => { + setup(); state.clear .mockResolvedValueOnce({ _tag: "Failure", cause: { error: new Error("The hub could not be reached.") }, }) .mockResolvedValueOnce({ _tag: "Success", value: undefined }); - await renderControl(); + await using screen = await render(control()); await act(async () => { - button().click(); + button(screen).click(); }); - expect(status()).toBe("The hub could not be reached."); - expect(button().disabled).toBe(false); + expect(status(screen)).toBe("The hub could not be reached."); + expect(button(screen).disabled).toBe(false); await act(async () => { - button().click(); + button(screen).click(); }); - expect(status()).toBe("Proxy cooldown cleared. Retry your request."); + expect(status(screen)).toBe("Proxy cooldown cleared. Retry your request."); }); diff --git a/apps/web/src/hooks/useCopyOnSelect.ts b/apps/web/src/hooks/useCopyOnSelect.ts index 252bac703fe9e..b7fcc0de5a30c 100644 --- a/apps/web/src/hooks/useCopyOnSelect.ts +++ b/apps/web/src/hooks/useCopyOnSelect.ts @@ -4,15 +4,6 @@ import { resolveCopyOnSelectText } from "~/lib/copyOnSelect"; import { writeTextToClipboard } from "./useCopyToClipboard"; -/** - * Copies a pointer-made text selection when the drag that made it ends. - * - * Scoped to `scope`: the gesture must start inside it and both ends of the - * resulting selection must land inside it, which is what keeps the behavior in - * the thread transcript instead of loose across the app. Passing - * `enabled: false` detaches the listeners, so the setting toggles without - * remounting the view. - */ export function useCopyOnSelect(input: { enabled: boolean; scope: HTMLElement | null; @@ -21,6 +12,7 @@ export function useCopyOnSelect(input: { const { enabled, scope } = input; const target = input.target ?? "selection"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the mousedown and mouseup listeners that copy a finished selection while enabled useEffect(() => { if (!enabled || scope === null) return; diff --git a/apps/web/src/lib/copyOnSelect.ts b/apps/web/src/lib/copyOnSelect.ts index 7f361214667db..5ff0bd8928951 100644 --- a/apps/web/src/lib/copyOnSelect.ts +++ b/apps/web/src/lib/copyOnSelect.ts @@ -1,12 +1,3 @@ -/** - * Copy-on-select: the terminal-emulator behavior where finishing a drag that - * selects text also puts that text on the clipboard. - * - * The decision lives here, apart from the DOM listeners in `useCopyOnSelect`, - * so the rules that matter — what counts as in scope, what is never copied — - * are testable without a browser. - */ - // Selecting inside a field is part of editing it, not a request to copy. // Auto-copying there would clobber the clipboard mid-edit, which is exactly // when a user is most likely to be holding something they mean to paste. diff --git a/apps/web/src/terminal/theme/ansiPalette.ts b/apps/web/src/terminal/theme/ansiPalette.ts index 03ccc56d41411..ecc5ec2ec8959 100644 --- a/apps/web/src/terminal/theme/ansiPalette.ts +++ b/apps/web/src/terminal/theme/ansiPalette.ts @@ -1,17 +1,3 @@ -/** - * Fork-local ANSI palette for the Ghostty surface. - * - * Upstream only hands Ghostty a foreground, background, and cursor color, so - * every SGR color falls through to libghostty's built-in palette, which is - * tuned for a dark background. On a light app theme that renders `\e[97m` and - * friends as white-on-white. This module builds the full 256-entry default - * palette the terminal is initialized with, picking a light or dark base set - * from the terminal background's luminance. - * - * Kept out of the upstream terminal files on purpose: the only edits it forces - * are the optional `palette` field on `GhosttyTheme` and one call site, so - * upstream merges stay cheap. - */ import type { GhosttyColor } from "../ghostty/core"; /** Number of entries libghostty expects for GHOSTTY_TERMINAL_OPT_COLOR_PALETTE. */ diff --git a/packages/client-runtime/src/contextWindow.ts b/packages/client-runtime/src/contextWindow.ts index f51d7b71bbd6a..52fae10e5c4f3 100644 --- a/packages/client-runtime/src/contextWindow.ts +++ b/packages/client-runtime/src/contextWindow.ts @@ -64,7 +64,6 @@ export function latestProviderTurnTokenUsage( ); } -/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */ export function deriveLatestContextWindowSnapshot( entries: ReadonlyArray<{ readonly item: ThreadStructureTurnItem; diff --git a/packages/client-runtime/src/state/issues.ts b/packages/client-runtime/src/state/issues.ts index ac7d84f47d229..6a004b04b2821 100644 --- a/packages/client-runtime/src/state/issues.ts +++ b/packages/client-runtime/src/state/issues.ts @@ -9,14 +9,6 @@ import { } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -/** - * Issues for the project the panel is following. - * - * Every read costs a request to the host, so answers are reused for a short while and refreshed - * when the reader asks. Writes run serially per environment and refresh what they changed: a - * comment and a state change both alter the issue that is on screen, and a new issue alters the - * list behind it. - */ export function createIssueEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { diff --git a/packages/contracts/src/issue.ts b/packages/contracts/src/issue.ts index d8bee66ba6241..b43677454522b 100644 --- a/packages/contracts/src/issue.ts +++ b/packages/contracts/src/issue.ts @@ -1,11 +1,3 @@ -/** - * Issues, as the clients read and write them. - * - * Deliberately narrower than the change request contract next door: an issue has no branches, - * no diff, no review and no merge, so what is left is a conversation with a state. The actor and - * label shapes are the change request's own, because a person and a label are the same thing - * whichever list they appear in. - */ import * as Schema from "effect/Schema"; import { @@ -209,23 +201,3 @@ export class IssueOperationError extends Schema.TaggedError export const IssueRpcError = Schema.Union([IssueUnavailableError, IssueOperationError]); export type IssueRpcError = typeof IssueRpcError.Type; - -/** What a host needs before its issues can be read, as a sentence the panel can show. */ -function issueProviderRequirement( - provider: SourceControlProviderKind, - reason: IssueUnavailableReason, -): string | null { - if (reason === "provider-unsupported") return null; - switch (provider) { - case "github": - return reason === "cli-missing" - ? "GitHub CLI (`gh`) is required to read issues on this host. Install it from https://cli.github.com/ and reload." - : "GitHub CLI is not authenticated. Run `gh auth login` and retry."; - case "forgejo": - return reason === "cli-missing" - ? "Forgejo CLI (`fj`) is required to sign in to an instance. Install it from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj auth login --host `." - : "Forgejo rejected the stored token, or it lacks the access this needs. Run `fj auth login --host ` again with issue read and write."; - default: - return null; - } -} diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index fbe9cad897612..6861771a41fb7 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -154,6 +154,7 @@ it.layer(NodeServices.layer)("isolated development identity", (it) => { yield* Command.runWith(main, { version: "0.0.0" })(["ensure", "ios", "simulator"]).pipe( Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Architecture, "arm64"), Effect.provideService(HostProcess.Environment, environment), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, {