Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
shouldClearThreadSelectionOnMouseDown,
sortLogicalProjectsForSidebar,
groupSettledThreadsByRecencyForSidebarV2,
isThreadSettledForDisplay,
resolveSettledTimestamp,
sortSettledThreadsForSidebarV2,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
Expand Down Expand Up @@ -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);
Expand Down
102 changes: 101 additions & 1 deletion apps/web/src/components/chat/ChatHeader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");
});
});
15 changes: 14 additions & 1 deletion apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
}
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/chat/QueuedMessageChips.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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(
<QueuedMessageChips queuedMessages={[]} onSteer={() => {}} onEdit={() => {}} />,
),
).toBe("");
});

it("shows queued text plus Steer and Edit affordances", () => {
const html = renderToStaticMarkup(
<QueuedMessageChips queuedMessages={[makeQueued()]} onSteer={() => {}} 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(
<QueuedMessageChips
queuedMessages={[
makeQueued({
text: "",
// Length is what the chip labels; shape is not rendered.
attachments: [{} as OrchestrationQueuedMessage["attachments"][number]],
}),
]}
onSteer={() => {}}
onEdit={() => {}}
/>,
);

expect(html).toContain("1 attachment(s)");
});
});
Loading
Loading