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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ release/
apps/web/.playwright
apps/web/playwright-report
apps/web/src/components/__screenshots__
.vitest-*
190 changes: 190 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { render } from "vitest-browser-react";

import { useComposerDraftStore } from "../composerDraftStore";
import {
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
type TerminalContextDraft,
} from "../lib/terminalContext";
import { isMacPlatform } from "../lib/utils";
import { getRouter } from "../router";
import { useStore } from "../store";
Expand Down Expand Up @@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
};
}

function createTerminalContext(input: {
id: string;
terminalLabel: string;
lineStart: number;
lineEnd: number;
text: string;
}): TerminalContextDraft {
return {
id: input.id,
threadId: THREAD_ID,
terminalId: `terminal-${input.id}`,
terminalLabel: input.terminalLabel,
lineStart: input.lineStart,
lineEnd: input.lineEnd,
text: input.text,
createdAt: NOW_ISO,
};
}

function createSnapshotForTargetUser(options: {
targetMessageId: MessageId;
targetText: string;
Expand Down Expand Up @@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
);
}

async function waitForSendButton(): Promise<HTMLButtonElement> {
return waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
"Unable to find send button.",
);
}

async function waitForInteractionModeButton(
expectedLabel: "Chat" | "Plan",
): Promise<HTMLButtonElement> {
Expand Down Expand Up @@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("keeps backspaced terminal context pills removed when a new one is added", async () => {
const removedLabel = "Terminal 1 lines 1-2";
const addedLabel = "Terminal 2 lines 9-10";
useComposerDraftStore.getState().addTerminalContext(
THREAD_ID,
createTerminalContext({
id: "ctx-removed",
terminalLabel: "Terminal 1",
lineStart: 1,
lineEnd: 2,
text: "bun i\nno changes",
}),
);

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-terminal-pill-backspace" as MessageId,
targetText: "terminal pill backspace target",
}),
});

try {
await vi.waitFor(
() => {
expect(document.body.textContent).toContain(removedLabel);
},
{ timeout: 8_000, interval: 16 },
);

const composerEditor = await waitForComposerEditor();
composerEditor.focus();
composerEditor.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
cancelable: true,
}),
);

await vi.waitFor(
() => {
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();
expect(document.body.textContent).not.toContain(removedLabel);
},
{ timeout: 8_000, interval: 16 },
);

useComposerDraftStore.getState().addTerminalContext(
THREAD_ID,
createTerminalContext({
id: "ctx-added",
terminalLabel: "Terminal 2",
lineStart: 9,
lineEnd: 10,
text: "git status\nOn branch main",
}),
);

await vi.waitFor(
() => {
const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
expect(document.body.textContent).toContain(addedLabel);
expect(document.body.textContent).not.toContain(removedLabel);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("disables send when the composer only contains an expired terminal pill", async () => {
const expiredLabel = "Terminal 1 line 4";
useComposerDraftStore.getState().addTerminalContext(
THREAD_ID,
createTerminalContext({
id: "ctx-expired-only",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
}),
);

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-expired-pill-disabled" as MessageId,
targetText: "expired pill disabled target",
}),
});

try {
await vi.waitFor(
() => {
expect(document.body.textContent).toContain(expiredLabel);
},
{ timeout: 8_000, interval: 16 },
);

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(true);
} finally {
await mounted.cleanup();
}
});

it("warns when sending text while omitting expired terminal pills", async () => {
const expiredLabel = "Terminal 1 line 4";
useComposerDraftStore.getState().addTerminalContext(
THREAD_ID,
createTerminalContext({
id: "ctx-expired-send-warning",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
}),
);
useComposerDraftStore
.getState()
.setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-expired-pill-warning" as MessageId,
targetText: "expired pill warning target",
}),
});

try {
await vi.waitFor(
() => {
expect(document.body.textContent).toContain(expiredLabel);
},
{ timeout: 8_000, interval: 16 },
);

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
expect(document.body.textContent).toContain(
"Expired terminal context omitted from message",
);
expect(document.body.textContent).not.toContain(expiredLabel);
expect(document.body.textContent).toContain("yoowaddup");
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("shows a pointer cursor for the running stop button", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";

import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";

describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
const state = deriveComposerSendState({
prompt: "\uFFFC",
imageCount: 0,
terminalContexts: [
{
id: "ctx-expired",
threadId: ThreadId.makeUnsafe("thread-1"),
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
createdAt: "2026-03-17T12:52:29.000Z",
},
],
});

expect(state.trimmedPrompt).toBe("");
expect(state.sendableTerminalContexts).toEqual([]);
expect(state.expiredTerminalContextCount).toBe(1);
expect(state.hasSendableContent).toBe(false);
});

it("keeps text sendable while excluding expired terminal pills", () => {
const state = deriveComposerSendState({
prompt: `yoo \uFFFC waddup`,
imageCount: 0,
terminalContexts: [
{
id: "ctx-expired",
threadId: ThreadId.makeUnsafe("thread-1"),
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
createdAt: "2026-03-17T12:52:29.000Z",
},
],
});

expect(state.trimmedPrompt).toBe("yoo waddup");
expect(state.expiredTerminalContextCount).toBe(1);
expect(state.hasSendableContent).toBe(true);
});
});

describe("buildExpiredTerminalContextToastCopy", () => {
it("formats clear empty-state guidance", () => {
expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({
title: "Expired terminal context won't be sent",
description: "Remove it or re-add it to include terminal output.",
});
});

it("formats omission guidance for sent messages", () => {
expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({
title: "Expired terminal contexts omitted from message",
description: "Re-add it if you want that terminal output included.",
});
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils";
import { getAppModelOptions } from "../appSettings";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import { Schema } from "effect";
import {
filterTerminalContextsWithText,
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
const WORKTREE_BRANCH_PREFIX = "t3code";
Expand Down Expand Up @@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: {
codex: getAppModelOptions("codex", settings.customCodexModels),
};
}

export function deriveComposerSendState(options: {
prompt: string;
imageCount: number;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
}): {
trimmedPrompt: string;
sendableTerminalContexts: TerminalContextDraft[];
expiredTerminalContextCount: number;
hasSendableContent: boolean;
} {
const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim();
const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
const expiredTerminalContextCount =
options.terminalContexts.length - sendableTerminalContexts.length;
return {
trimmedPrompt,
sendableTerminalContexts,
expiredTerminalContextCount,
hasSendableContent:
trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0,
};
}

export function buildExpiredTerminalContextToastCopy(
expiredTerminalContextCount: number,
variant: "omitted" | "empty",
): { title: string; description: string } {
const count = Math.max(1, Math.floor(expiredTerminalContextCount));
const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts";
if (variant === "empty") {
return {
title: `${noun} won't be sent`,
description: "Remove it or re-add it to include terminal output.",
};
}
return {
title: `${noun} omitted from message`,
description: "Re-add it if you want that terminal output included.",
};
}
Loading