Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ release/
apps/web/.playwright
apps/web/playwright-report
apps/web/src/components/__screenshots__
.vitest-*
.vitest-*
.tanstack
273 changes: 206 additions & 67 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@
};
}

function withProjectScripts(
snapshot: OrchestrationReadModel,
scripts: OrchestrationReadModel["projects"][number]["scripts"],
): OrchestrationReadModel {
return {
...snapshot,
projects: snapshot.projects.map((project) =>
project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project,
),
};
}

function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-plan-target" as MessageId,
Expand Down Expand Up @@ -573,6 +585,58 @@
);
}

async function waitForServerConfigToApply(): Promise<void> {
await vi.waitFor(
() => {
expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true);
},
{ timeout: 8_000, interval: 16 },
);
await waitForLayout();
}

function dispatchChatNewShortcut(): void {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "o",
shiftKey: true,
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);
}

async function triggerChatNewShortcutUntilPath(
router: ReturnType<typeof getRouter>,
predicate: (pathname: string) => boolean,
errorMessage: string,
): Promise<string> {
let pathname = router.state.location.pathname;
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
dispatchChatNewShortcut();
await waitForLayout();
pathname = router.state.location.pathname;
if (predicate(pathname)) {
return pathname;
}
}
throw new Error(`${errorMessage} Last path: ${pathname}`);
}

async function waitForNewThreadShortcutLabel(): Promise<void> {
const newThreadButton = page.getByTestId("new-thread-button");
await expect.element(newThreadButton).toBeInTheDocument();
await newThreadButton.hover();
const shortcutLabel = isMacPlatform(navigator.platform)
? "New thread (⇧⌘O)"
: "New thread (Ctrl+Shift+O)";
await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument();
}

async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
const images = Array.from(scope.querySelectorAll("img"));
if (images.length === 0) {
Expand Down Expand Up @@ -976,6 +1040,145 @@
}
});

it("runs project scripts from local draft threads at the project cwd", async () => {
useComposerDraftStore.setState({
draftThreadsByThreadId: {
[THREAD_ID]: {
projectId: PROJECT_ID,
createdAt: NOW_ISO,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
envMode: "local",
},
},
projectDraftThreadIdByProjectId: {
[PROJECT_ID]: THREAD_ID,
},
});

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: withProjectScripts(createDraftOnlySnapshot(), [
{
id: "lint",
name: "Lint",
command: "bun run lint",
icon: "lint",
runOnWorktreeCreate: false,
},
]),
});

try {
const runButton = await waitForElement(
() =>
Array.from(document.querySelectorAll("button")).find(
(button) => button.title === "Run Lint",
) as HTMLButtonElement | null,
"Unable to find Run Lint button.",
);
runButton.click();

await vi.waitFor(
() => {
const openRequest = wsRequests.find(
(request) => request._tag === WS_METHODS.terminalOpen,
);
expect(openRequest).toMatchObject({
_tag: WS_METHODS.terminalOpen,
threadId: THREAD_ID,
cwd: "/repo/project",
env: {
T3CODE_PROJECT_ROOT: "/repo/project",
},
});
},
{ timeout: 8_000, interval: 16 },
);

await vi.waitFor(
() => {
const writeRequest = wsRequests.find(
(request) => request._tag === WS_METHODS.terminalWrite,
);
expect(writeRequest).toMatchObject({
_tag: WS_METHODS.terminalWrite,
threadId: THREAD_ID,
data: "bun run lint\r",
});
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("runs project scripts from worktree draft threads at the worktree cwd", async () => {
useComposerDraftStore.setState({
draftThreadsByThreadId: {
[THREAD_ID]: {
projectId: PROJECT_ID,
createdAt: NOW_ISO,
runtimeMode: "full-access",
interactionMode: "default",
branch: "feature/draft",
worktreePath: "/repo/worktrees/feature-draft",
envMode: "worktree",
},
},
projectDraftThreadIdByProjectId: {
[PROJECT_ID]: THREAD_ID,
},
});

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: withProjectScripts(createDraftOnlySnapshot(), [
{
id: "test",
name: "Test",
command: "bun run test",
icon: "test",
runOnWorktreeCreate: false,
},
]),
});

try {
const runButton = await waitForElement(
() =>
Array.from(document.querySelectorAll("button")).find(
(button) => button.title === "Run Test",
) as HTMLButtonElement | null,
"Unable to find Run Test button.",
);
runButton.click();

await vi.waitFor(
() => {
const openRequest = wsRequests.find(
(request) => request._tag === WS_METHODS.terminalOpen,
);
expect(openRequest).toMatchObject({
_tag: WS_METHODS.terminalOpen,
threadId: THREAD_ID,
cwd: "/repo/worktrees/feature-draft",
env: {
T3CODE_PROJECT_ROOT: "/repo/project",
T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft",
},
});
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down Expand Up @@ -1083,7 +1286,7 @@

await vi.waitFor(
() => {
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();

Check failure on line 1289 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > keeps backspaced terminal context pills removed when a new one is added

AssertionError: expected { prompt: '\ufffc', images: [], …(9) } to be undefined - Expected: undefined + Received: { "codexFastMode": false, "effort": null, "images": [], "interactionMode": null, "model": null, "nonPersistedImageIds": [], "persistedAttachments": [], "prompt": "", "provider": null, "runtimeMode": null, "terminalContexts": [ { "createdAt": "2026-03-04T12:00:00.000Z", "id": "ctx-removed", "lineEnd": 2, "lineStart": 1, "terminalId": "terminal-ctx-removed", "terminalLabel": "Terminal 1", "text": "bun i no changes", "threadId": "thread-browser-test", }, ], } ❯ toBeUndefined src/components/ChatView.browser.tsx:1289:79
expect(document.body.textContent).not.toContain(removedLabel);
},
{ timeout: 8_000, interval: 16 },
Expand Down Expand Up @@ -1277,60 +1480,6 @@
}
});

it("creates a new thread from the global chat.new shortcut", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-chat-shortcut-test" as MessageId,
targetText: "chat shortcut test",
}),
configureFixture: (nextFixture) => {
nextFixture.serverConfig = {
...nextFixture.serverConfig,
keybindings: [
{
command: "chat.new",
shortcut: {
key: "o",
metaKey: false,
ctrlKey: false,
shiftKey: true,
altKey: false,
modKey: true,
},
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
],
};
},
});

try {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "o",
shiftKey: true,
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path),
"Route should have changed to a new draft thread UUID from the shortcut.",
);
} finally {
await mounted.cleanup();
}
});

it("creates a fresh draft after the previous draft thread is promoted", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down Expand Up @@ -1365,6 +1514,8 @@
try {
const newThreadButton = page.getByTestId("new-thread-button");
await expect.element(newThreadButton).toBeInTheDocument();
await waitForNewThreadShortcutLabel();
await waitForServerConfigToApply();
await newThreadButton.click();

const promotedThreadPath = await waitForURL(
Expand All @@ -1378,19 +1529,7 @@
syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
useComposerDraftStore.getState().clearDraftThread(promotedThreadId);

const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "o",
shiftKey: true,
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);

const freshThreadPath = await waitForURL(
const freshThreadPath = await triggerChatNewShortcutUntilPath(
mounted.router,
(path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath,
"Shortcut should create a fresh draft instead of reusing the promoted thread.",
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
commandForProjectScript,
nextProjectScriptId,
projectScriptCwd,
projectScriptRuntimeEnv,
projectScriptIdFromCommand,
setupProjectScript,
Expand Down Expand Up @@ -980,7 +981,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
latestTurnSettled,
timelineEntries,
]);
const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
const gitCwd = activeProject
? projectScriptCwd({
project: { cwd: activeProject.cwd },
worktreePath: activeThread?.worktreePath ?? null,
})
: null;
const composerTriggerKind = composerTrigger?.kind ?? null;
const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
const isPathTrigger = composerTriggerKind === "path";
Expand Down Expand Up @@ -1290,12 +1296,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
worktreePath?: string | null;
preferNewTerminal?: boolean;
rememberAsLastInvoked?: boolean;
allowLocalDraftThread?: boolean;
},
) => {
const api = readNativeApi();
if (!api || !activeThreadId || !activeProject || !activeThread) return;
if (!isServerThread && !options?.allowLocalDraftThread) return;
if (options?.rememberAsLastInvoked !== false) {
setLastInvokedScriptByProjectId((current) => {
if (current[activeProject.id] === script.id) return current;
Expand Down Expand Up @@ -1364,7 +1368,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThread,
activeThreadId,
gitCwd,
isServerThread,
setTerminalOpen,
setThreadError,
storeNewTerminal,
Expand Down Expand Up @@ -2548,7 +2551,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setupScriptOptions: Parameters<typeof runProjectScript>[1] = {
worktreePath: nextThreadWorktreePath,
rememberAsLastInvoked: false,
allowLocalDraftThread: createdServerThreadForLocalDraft,
};
if (nextThreadWorktreePath) {
setupScriptOptions.cwd = nextThreadWorktreePath;
Expand Down Expand Up @@ -3412,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeThreadTitle={activeThread.title}
activeProjectName={activeProject?.name}
isGitRepo={isGitRepo}
openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
openInCwd={gitCwd}
activeProjectScripts={activeProject?.scripts}
preferredScriptId={
activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
Expand Down
Loading
Loading