diff --git a/AGENTS.md b/AGENTS.md index f22e0202f69..634efeedcf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,14 @@ branches. same side. Prefer durable `commit: "*"` + path policies; exact SHAs go stale after every rewrite. During rebase, `theirs` = commit being replayed, `ours` = new base. Documented in [docs/fork-stack.md](./docs/fork-stack.md) ("Conflict resolutions"). +- **Product conflicts (shared UI / app code):** never blind whole-file `ours`/`theirs` on shared + product paths. 3-way merge or re-apply the feature commit; run a pre/post parity check so helpers + and tests cannot survive while JSX/wiring is dropped (see #154 remote Open in VS Code button). + Full rules: [docs/fork-stack.md](./docs/fork-stack.md) ("Product conflicts"). +- **Fork product changes need existence/behavior tests:** every user-visible or behavioral fork + change must land with a test that fails if the surface disappears (pure helpers alone are not + enough). Prefer pure gates + `aria-label`/`data-testid` existence, or markers in + `apps/web/src/forkSurfaceExistence.test.ts` for chrome. - **Integration compose lockfiles:** overlay lock commits diverge by design. Compose skips lockfile-only commits, defers lock-only conflicts, and regenerates one integration `pnpm-lock.yaml` at the end. Never push a partial `fork/integration` after a lock conflict. @@ -180,6 +188,9 @@ Run from the repository root, in order: is what the package uses. - Backend / contracts / runtime behavior changes **must** include and run focused tests for the changed behavior. + - Fork product / UI changes **must** include an existence or behavior assertion that fails if + the surface is dropped (not only pure helpers). See `apps/web/src/forkSurfaceExistence.test.ts` + and [docs/fork-stack.md](./docs/fork-stack.md) (“Product conflicts”). 5. **Do not push** if steps 1–2 fail, or if required steps 3–4 fail. Fix first. **Ordinary feature PRs (based on `fork/changes`):** full-workspace `vp run test` is optional unless diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 79f89e84709..40a93db2bef 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -35,6 +35,8 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, groupSettledThreadsByRecencyForSidebarV2, + isThreadSettledForDisplay, + resolveSettledTimestamp, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, @@ -1164,6 +1166,98 @@ describe("sortSettledThreadsForSidebarV2", () => { }); }); +describe("resolveSettledTimestamp", () => { + it("prefers explicit settledAt over later message activity", () => { + expect( + resolveSettledTimestamp({ + settledAt: "2026-03-09T10:00:00.000Z", + latestUserMessageAt: "2026-03-09T12:00:00.000Z", + latestTurn: null, + updatedAt: "2026-03-09T13:00:00.000Z", + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("falls back to the latest activity stamp when settledAt is missing", () => { + expect( + resolveSettledTimestamp({ + settledAt: null, + latestUserMessageAt: "2026-03-09T09:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T11:00:00.000Z" }), + updatedAt: "2026-03-09T08:00:00.000Z", + }), + ).toBe("2026-03-09T11:00:00.000Z"); + }); +}); + +describe("isThreadSettledForDisplay", () => { + const now = "2026-04-10T00:00:00.000Z"; + const baseThread = { + id: ThreadId.make("thread-settled-display"), + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), + title: "Settled display", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: now, + archivedAt: null, + latestTurn: null, + latestUserMessageAt: "2026-04-01T00:00:00.000Z", + branch: null, + worktreePath: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + settledOverride: "settled" as const, + settledAt: now, + }; + + it("never treats threads as settled when the server lacks threadSettlement", () => { + const serverConfigs = { + get(_environmentId: string) { + return { + environment: { + capabilities: { threadSettlement: false }, + }, + }; + }, + }; + + expect( + isThreadSettledForDisplay(baseThread, { + serverConfigs, + now, + autoSettleAfterDays: 7, + changeRequestState: null, + }), + ).toBe(false); + }); + + it("honors settled override when the server supports settlement", () => { + const serverConfigs = { + get(_environmentId: string) { + return { + environment: { + capabilities: { threadSettlement: true }, + }, + }; + }, + }; + + expect( + isThreadSettledForDisplay(baseThread, { + serverConfigs, + now, + autoSettleAfterDays: 7, + changeRequestState: null, + }), + ).toBe(true); + }); +}); + describe("groupSettledThreadsByRecencyForSidebarV2", () => { // Fixed local afternoon so last-hour and earlier-today both fit the day. const now = new Date(2026, 2, 15, 14, 30, 0); diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index dbe05f2e477..e4a320a308e 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -7,9 +7,22 @@ import { type ConnectionCatalogEntry, } from "@t3tools/client-runtime/connection"; import * as Option from "effect/Option"; +// @effect-diagnostics nodeBuiltinImport:off - existence contract reads source text on disk. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { describe, expect, it } from "vite-plus/test"; -import { resolveRemoteVscodeOpenTarget, shouldShowOpenInPicker } from "./ChatHeader"; +import { + resolveRemoteVscodeOpenTarget, + shouldOfferRemoteVscodeOpen, + shouldShowOpenInPicker, +} from "./ChatHeader"; + +const chatHeaderSource = NodeFS.readFileSync( + NodePath.join(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "ChatHeader.tsx"), + "utf8", +); describe("shouldShowOpenInPicker", () => { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -55,6 +68,35 @@ describe("shouldShowOpenInPicker", () => { }); }); +describe("shouldOfferRemoteVscodeOpen", () => { + it("offers remote open for a named project when the local picker is hidden", () => { + expect( + shouldOfferRemoteVscodeOpen({ + activeProjectName: "codething-mvp", + showOpenInPicker: false, + }), + ).toBe(true); + }); + + it("never offers remote open when the local OpenInPicker is shown", () => { + expect( + shouldOfferRemoteVscodeOpen({ + activeProjectName: "codething-mvp", + showOpenInPicker: true, + }), + ).toBe(false); + }); + + it("never offers remote open without an active project", () => { + expect( + shouldOfferRemoteVscodeOpen({ + activeProjectName: undefined, + showOpenInPicker: false, + }), + ).toBe(false); + }); +}); + describe("resolveRemoteVscodeOpenTarget", () => { const environmentId = EnvironmentId.make("environment-remote"); @@ -119,4 +161,62 @@ describe("resolveRemoteVscodeOpenTarget", () => { uri: "vscode://vscode-remote/ssh-remote+tester%40remote.example.test/home/tester/project%20with%20spaces?windowId=_blank", }); }); + + it("returns null for non-absolute cwd, missing entry, or empty hostname", () => { + expect( + resolveRemoteVscodeOpenTarget({ + entry: null, + cwd: "/home/tester/projects/example", + }), + ).toBeNull(); + expect( + resolveRemoteVscodeOpenTarget({ + entry: { + target: new BearerConnectionTarget({ + environmentId, + label: "remote-vm", + connectionId: "bearer:remote-vm", + }), + profile: Option.none(), + }, + cwd: "/home/tester/projects/example", + }), + ).toBeNull(); + expect( + resolveRemoteVscodeOpenTarget({ + entry: { + target: new BearerConnectionTarget({ + environmentId, + label: "remote-vm", + connectionId: "bearer:remote-vm", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:remote-vm", + environmentId, + label: "remote-vm", + httpBaseUrl: "http://gateway.example.test:8080/", + wsBaseUrl: "ws://gateway.example.test:8080/", + }), + ), + }, + cwd: "relative/path", + }), + ).toBeNull(); + }); +}); + +describe("ChatHeader remote Open in VS Code surface (anti stack-drop)", () => { + it("still wires the remote control through the pure gate into header JSX", () => { + // Pure helpers alone are not enough: #154 proved stack recovery can keep + // resolveRemoteVscodeOpenTarget while deleting the button. These markers + // must remain co-located in ChatHeader.tsx. + expect(chatHeaderSource).toContain("shouldOfferRemoteVscodeOpen"); + expect(chatHeaderSource).toContain("resolveRemoteVscodeOpenTarget"); + expect(chatHeaderSource).toContain("remoteVscodeTarget"); + expect(chatHeaderSource).toContain("Open in VS Code Remote SSH on"); + expect(chatHeaderSource).toContain("Open VS Code Remote SSH:"); + expect(chatHeaderSource).toContain("shell.openExternal"); + expect(chatHeaderSource).toContain("VisualStudioCode"); + }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index e3f2d7c584d..d1a8e97b124 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -61,6 +61,19 @@ export function shouldShowOpenInPicker(input: { ); } +/** + * Remote Open-in-VS-Code is mutually exclusive with the local OpenInPicker: + * only offer it for a named project when the local picker is hidden (non-primary + * environments). Pure gate so stack recovery cannot keep the URI helper while + * dropping the product surface without a failing unit test. + */ +export function shouldOfferRemoteVscodeOpen(input: { + readonly activeProjectName: string | undefined; + readonly showOpenInPicker: boolean; +}): boolean { + return Boolean(input.activeProjectName) && !input.showOpenInPicker; +} + function encodeRemotePath(path: string): string { return path.split("/").map(encodeURIComponent).join("/"); } @@ -136,7 +149,7 @@ export const ChatHeader = memo(function ChatHeader({ }); const remoteVscodeTarget = useMemo( () => - activeProjectName && !showOpenInPicker + shouldOfferRemoteVscodeOpen({ activeProjectName, showOpenInPicker }) ? resolveRemoteVscodeOpenTarget({ entry: activeEnvironment?.entry ?? null, cwd: openInCwd, diff --git a/apps/web/src/components/chat/QueuedMessageChips.test.tsx b/apps/web/src/components/chat/QueuedMessageChips.test.tsx new file mode 100644 index 00000000000..891a478f7bf --- /dev/null +++ b/apps/web/src/components/chat/QueuedMessageChips.test.tsx @@ -0,0 +1,56 @@ +import { MessageId, type OrchestrationQueuedMessage } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { QueuedMessageChips } from "./QueuedMessageChips"; + +function makeQueued( + overrides: Partial = {}, +): OrchestrationQueuedMessage { + return { + messageId: MessageId.make("msg-queued-1"), + text: "follow up after this turn", + attachments: [], + queuedAt: "2026-07-28T12:00:00.000Z", + ...overrides, + }; +} + +describe("QueuedMessageChips", () => { + it("renders nothing when the queue is empty", () => { + expect( + renderToStaticMarkup( + {}} onEdit={() => {}} />, + ), + ).toBe(""); + }); + + it("shows queued text plus Steer and Edit affordances", () => { + const html = renderToStaticMarkup( + {}} onEdit={() => {}} />, + ); + + expect(html).toContain("follow up after this turn"); + expect(html).toContain('aria-label="Edit queued message"'); + expect(html).toContain("Steer: send now, interrupting the current step"); + expect(html).toContain("Steer"); + }); + + it("labels attachment-only queued messages", () => { + const html = renderToStaticMarkup( + {}} + onEdit={() => {}} + />, + ); + + expect(html).toContain("1 attachment(s)"); + }); +}); diff --git a/apps/web/src/components/listEnvironmentFilter.test.ts b/apps/web/src/components/listEnvironmentFilter.test.ts new file mode 100644 index 00000000000..8b16538724c --- /dev/null +++ b/apps/web/src/components/listEnvironmentFilter.test.ts @@ -0,0 +1,94 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_HIDE_SETTLED_PROJECTS, + DEFAULT_HIDE_SETTLED_RECENT, + DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, + DEFAULT_WEB_LIST_MODE, + DEFAULT_WEB_THREAD_GROUPING, + LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, + LIST_HIDE_SETTLED_RECENT_STORAGE_KEY, + SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY, + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, + WebListModeSchema, + defaultThreadGroupingFromLegacyModeStorage, + isAllEnvironmentsSelected, + matchesEnvironmentFilter, + resolveSelectedEnvironmentIds, + toggleEnvironmentId, + usesFlatThreadGrouping, + usesProjectThreadGrouping, +} from "./listEnvironmentFilter"; + +const envA = EnvironmentId.make("environment-a"); +const envB = EnvironmentId.make("environment-b"); +const decodeListMode = Schema.decodeUnknownSync(WebListModeSchema); + +describe("list environment multi-select", () => { + it("treats an empty selection as all environments", () => { + expect(matchesEnvironmentFilter(envA, [])).toBe(true); + expect(isAllEnvironmentsSelected([])).toBe(true); + }); + + it("starts a singleton selection when toggling from empty (all)", () => { + expect(toggleEnvironmentId([], envA)).toEqual([envA]); + }); + + it("adds and removes ids without collapsing back to empty until last deselect", () => { + expect(toggleEnvironmentId([envA], envB)).toEqual([envA, envB]); + expect(toggleEnvironmentId([envA, envB], envA)).toEqual([envB]); + }); + + it("drops selected ids that are no longer available", () => { + expect(resolveSelectedEnvironmentIds([envA, envB], new Set([envA]))).toEqual([envA]); + expect(resolveSelectedEnvironmentIds([], new Set([envA]))).toEqual([]); + }); +}); + +describe("threads list mode and grouping prefs", () => { + it("maps legacy recent/projects mode values onto the combined Threads surface", () => { + expect(decodeListMode("threads")).toBe("threads"); + expect(decodeListMode("board")).toBe("board"); + expect(decodeListMode("recent")).toBe("threads"); + expect(decodeListMode("projects")).toBe("threads"); + expect(DEFAULT_WEB_LIST_MODE).toBe("threads"); + }); + + it("migrates unset grouping from legacy mode storage", () => { + expect(defaultThreadGroupingFromLegacyModeStorage('"recent"')).toBe("recency"); + expect(defaultThreadGroupingFromLegacyModeStorage('"projects"')).toBe("project"); + expect(defaultThreadGroupingFromLegacyModeStorage(null)).toBe(DEFAULT_WEB_THREAD_GROUPING); + expect(DEFAULT_WEB_THREAD_GROUPING).toBe("project"); + }); + + it("classifies project vs flat groupings for hide-settled / shelf behavior", () => { + expect(usesProjectThreadGrouping("project")).toBe(true); + expect(usesProjectThreadGrouping("recency")).toBe(false); + expect(usesFlatThreadGrouping("recency")).toBe(true); + expect(usesFlatThreadGrouping("none")).toBe(true); + expect(usesFlatThreadGrouping("project")).toBe(false); + }); +}); + +describe("hide-settled and Sidebar V2 settled shelf defaults", () => { + it("hides settled by default on recency/none and shows them on project groups", () => { + expect(DEFAULT_HIDE_SETTLED_RECENT).toBe(true); + expect(DEFAULT_HIDE_SETTLED_PROJECTS).toBe(false); + expect(LIST_HIDE_SETTLED_RECENT_STORAGE_KEY).toBe("t3code:list:hide-settled-recent:v1"); + expect(LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY).toBe("t3code:list:hide-settled-projects:v1"); + }); + + it("keeps V2 settled recency headers and expanded shelf as defaults", () => { + expect(DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS).toBe(true); + expect(DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED).toBe(true); + expect(SIDEBAR_V2_SETTLED_RECENCY_HEADERS_STORAGE_KEY).toBe( + "t3code:sidebar-v2:settled-recency-headers:v1", + ); + expect(SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY).toBe( + "t3code:sidebar-v2:settled-shelf-expanded:v1", + ); + }); +}); diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts new file mode 100644 index 00000000000..5a96f2c95d8 --- /dev/null +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -0,0 +1,48 @@ +/** + * Existence contracts for fork-only product surfaces that pure helper tests can + * leave green after a partial stack conflict resolution (see #154). + * + * Prefer pure behavior tests next to each feature; keep this file as the last + * line of defense for JSX chrome that is easy to drop while helpers remain. + */ +// @effect-diagnostics nodeBuiltinImport:off - existence contract reads source text on disk. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +const root = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +function readSrc(relativePath: string): string { + return NodeFS.readFileSync(NodePath.join(root, relativePath), "utf8"); +} + +describe("fork surface existence (anti stack-drop)", () => { + it("classic sidebar keeps the collapsible Settled shelf chrome", () => { + const sidebar = readSrc("components/Sidebar.tsx"); + expect(sidebar).toContain('data-testid="sidebar-v1-settled-shelf-toggle"'); + expect(sidebar).toContain("Hide settled"); + expect(sidebar).toContain('data-testid="sidebar-v1-settled-recency-headers"'); + expect(sidebar).toContain("sidebar-v1-settled-recency-"); + }); + + it("Sidebar V2 keeps Settled shelf labeling and new-thread affordance", () => { + const sidebarV2 = readSrc("components/SidebarV2.tsx"); + expect(sidebarV2).toContain("Settled shelf"); + expect(sidebarV2).toMatch(/New thread|new thread/i); + expect(sidebarV2).toContain("ProjectServerContextLine"); + }); + + it("chat header keeps remote Open in VS Code control markers", () => { + const header = readSrc("components/chat/ChatHeader.tsx"); + expect(header).toContain("shouldOfferRemoteVscodeOpen"); + expect(header).toContain("Open in VS Code Remote SSH on"); + expect(header).toContain("shell.openExternal"); + }); + + it("queued message chips keep edit + steer labels", () => { + const chips = readSrc("components/chat/QueuedMessageChips.tsx"); + expect(chips).toContain('aria-label="Edit queued message"'); + expect(chips).toContain("Steer: send now, interrupting the current step"); + }); +}); diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 73c12b86cbe..68e504ced5f 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -250,6 +250,32 @@ Required workflow when automation stops on a conflict: The stack conflict summary prints ready-to-paste JSON for both `*` and exact-SHA forms. +### Product conflicts (shared UI / app code — never blind whole-file) + +`conflictResolutions` with whole-file `ours`/`theirs` is appropriate for **fork-owned** paths and +boilerplate (`pnpm-lock.yaml`, pure fork-only modules). It is **not** safe for shared product files +where both the new base and the replayed commit carry real behavior (classic example: +`apps/web/src/components/chat/ChatHeader.tsx` — recovery once kept +`resolveRemoteVscodeOpenTarget` + unit tests and **dropped the remote Open in VS Code header +button**, so CI stayed green while the control vanished; restored in #154). + +When a conflict touches `apps/**` or `packages/**` product code: + +1. **Do not** apply a durable whole-file `*` policy unless the path is documented as always taking + one side for every rewrite. +2. **3-way merge or re-apply** the known-good feature commit after a clean base; do not invent a + partial hand merge that keeps helpers/tests and drops JSX / wiring. +3. **Parity check** before resume/push: `git diff` the pre-rewrite tip vs the resolved path; if a + symbol remains only in tests (or pure helpers) while the product surface is gone, the resolution + is incomplete. +4. **Tests that would have failed #154:** every fork product change needs an existence or behavior + assertion for the surface users see — pure URI/helper tests alone are insufficient. Prefer: + - exported pure gates (`shouldOfferRemoteVscodeOpen`, list defaults, …), **and** + - one existence check (`aria-label` / `data-testid` via `renderToStaticMarkup`, or source markers + in `apps/web/src/forkSurfaceExistence.test.ts` for chrome that is hard to mount). +5. After resolving, run the focused tests for the conflicted package **and** the root pre-push gate + for the layer (see AGENTS.md). + ### Integration overlay compose and lockfiles `node scripts/compose-integration-overlays.ts` rebuilds `fork/integration` by cherry-picking each diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 7f327cf33a2..9ab3627e5b6 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -35,4 +35,30 @@ describe("deriveProjectGroupLabel", () => { "macs-holding/internal", ); }); + + it("falls back to the representative title when there is no repository identity", () => { + const project = { + title: "Local sandbox", + repositoryIdentity: null, + }; + + expect(deriveProjectGroupLabel({ representative: project, members: [project] })).toBe( + "Local sandbox", + ); + }); + + it("falls back to the representative title when members disagree on repo names", () => { + const left = { + title: "Workspace title", + repositoryIdentity: repositoryIdentity("pingdotgg", "t3code"), + }; + const right = { + title: "Workspace title", + repositoryIdentity: repositoryIdentity("other", "different"), + }; + + expect(deriveProjectGroupLabel({ representative: left, members: [left, right] })).toBe( + "Workspace title", + ); + }); });