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
143 changes: 141 additions & 2 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { render } from "vitest-browser-react";

import { useComposerDraftStore } from "../composerDraftStore";
import { isMacPlatform } from "../lib/utils";
import { getRouter } from "../router";
import { useStore } from "../store";
import { estimateTimelineMessageHeight } from "./timelineHeight";
Expand Down Expand Up @@ -353,7 +354,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}

function resolveWsRpc(tag: string): unknown {
function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const tag = body._tag;
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
return fixture.snapshot;
}
Expand Down Expand Up @@ -395,6 +397,19 @@ function resolveWsRpc(tag: string): unknown {
truncated: false,
};
}
if (tag === WS_METHODS.terminalOpen) {
return {
threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
terminalId: typeof body.terminalId === "string" ? body.terminalId : "default",
cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project",
status: "running",
pid: 123,
history: "",
exitCode: null,
exitSignal: null,
updatedAt: NOW_ISO,
};
}
return {};
}

Expand Down Expand Up @@ -423,7 +438,7 @@ const worker = setupWorker(
client.send(
JSON.stringify({
id: request.id,
result: resolveWsRpc(method),
result: resolveWsRpc(request.body),
}),
);
});
Expand Down Expand Up @@ -1048,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

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,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId,
targetText: "promoted draft 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 newThreadButton = page.getByTestId("new-thread-button");
await expect.element(newThreadButton).toBeInTheDocument();
await newThreadButton.click();

const promotedThreadPath = await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path),
"Route should have changed to a promoted draft thread UUID.",
);
const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;

const { syncServerReadModel } = useStore.getState();
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(
mounted.router,
(path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath,
"Shortcut should create a fresh draft instead of reusing the promoted thread.",
);
expect(freshThreadPath).not.toBe(promotedThreadPath);
} finally {
await mounted.cleanup();
}
});

it("keeps long proposed plans lightweight until the user expands them", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
32 changes: 17 additions & 15 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
type TurnDiffSummary,
} from "../types";
Expand Down Expand Up @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
import { resolveAppModelSelection, useAppSettings } from "../appSettings";
import { isTerminalFocused } from "../lib/terminalFocus";
import {
type ComposerImageAttachment,
type DraftThreadEnvMode,
Expand Down Expand Up @@ -1013,7 +1014,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
(activeThread.messages.length > 0 ||
(activeThread.session !== null && activeThread.session.status !== "closed")),
);
const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
const activeTerminalGroup =
terminalState.terminalGroups.find(
(group) => group.id === terminalState.activeTerminalGroupId,
) ??
terminalState.terminalGroups.find((group) =>
group.terminalIds.includes(terminalState.activeTerminalId),
) ??
null;
const hasReachedSplitLimit =
(activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
const setThreadError = useCallback(
(targetThreadId: ThreadId | null, error: string | null) => {
if (!targetThreadId) return;
Expand Down Expand Up @@ -1061,17 +1071,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
setTerminalOpen(!terminalState.terminalOpen);
}, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
const splitTerminal = useCallback(() => {
if (!activeThreadId || hasReachedTerminalLimit) return;
if (!activeThreadId || hasReachedSplitLimit) return;
const terminalId = `terminal-${randomUUID()}`;
storeSplitTerminal(activeThreadId, terminalId);
setTerminalFocusRequestId((value) => value + 1);
}, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]);
}, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);
const createNewTerminal = useCallback(() => {
if (!activeThreadId || hasReachedTerminalLimit) return;
if (!activeThreadId) return;
const terminalId = `terminal-${randomUUID()}`;
storeNewTerminal(activeThreadId, terminalId);
setTerminalFocusRequestId((value) => value + 1);
}, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]);
}, [activeThreadId, storeNewTerminal]);
const activateTerminal = useCallback(
(terminalId: string) => {
if (!activeThreadId) return;
Expand Down Expand Up @@ -1138,8 +1148,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
DEFAULT_THREAD_TERMINAL_ID;
const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
const shouldCreateNewTerminal =
wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
const shouldCreateNewTerminal = wantsNewTerminal;
const targetTerminalId = shouldCreateNewTerminal
? `terminal-${randomUUID()}`
: baseTerminalId;
Expand Down Expand Up @@ -1914,13 +1923,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [activeThreadId, focusComposer, terminalState.terminalOpen]);

useEffect(() => {
const isTerminalFocused = (): boolean => {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return false;
if (activeElement.classList.contains("xterm-helper-textarea")) return true;
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";

import {
hasUnseenCompletion,
resolveThreadRowClassName,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
} from "./Sidebar.logic";
Expand Down Expand Up @@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => {
).toMatchObject({ label: "Completed", pulse: false });
});
});

describe("resolveThreadRowClassName", () => {
it("uses the darker selected palette when a thread is both selected and active", () => {
const className = resolveThreadRowClassName({ isActive: true, isSelected: true });
expect(className).toContain("bg-primary/22");
expect(className).toContain("hover:bg-primary/26");
expect(className).toContain("dark:bg-primary/30");
expect(className).not.toContain("bg-accent/85");
});

it("uses selected hover colors for selected threads", () => {
const className = resolveThreadRowClassName({ isActive: false, isSelected: true });
expect(className).toContain("bg-primary/15");
expect(className).toContain("hover:bg-primary/19");
expect(className).toContain("dark:bg-primary/22");
expect(className).not.toContain("hover:bg-accent");
});

it("keeps the accent palette for active-only threads", () => {
const className = resolveThreadRowClassName({ isActive: true, isSelected: false });
expect(className).toContain("bg-accent/85");
expect(className).toContain("hover:bg-accent");
});
});
32 changes: 32 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Thread } from "../types";
import { cn } from "../lib/utils";
import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";

export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
Expand Down Expand Up @@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
}

export function resolveThreadRowClassName(input: {
isActive: boolean;
isSelected: boolean;
}): string {
const baseClassName =
"h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-0";

if (input.isSelected && input.isActive) {
return cn(
baseClassName,
"bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36",
);
}

if (input.isSelected) {
return cn(
baseClassName,
"bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28",
);
}

if (input.isActive) {
return cn(
baseClassName,
"bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70",
);
}

return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground");
}

export function resolveThreadStatusPill(input: {
thread: ThreadStatusInput;
hasPendingApprovals: boolean;
Expand Down
Loading
Loading