Skip to content
Closed
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 apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t");
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
assert.equal(defaultsByCommand.get("chat.find"), "mod+f");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized"));
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
LinkIcon,
MessageSquareIcon,
PaletteIcon,
SearchIcon,
SettingsIcon,
SquarePenIcon,
TextSearchIcon,
Expand Down Expand Up @@ -149,6 +150,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFilePicker } from "./files/ProjectFilePicker";
import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
import { openChatFind } from "./chat/chatFindBus";
import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
import {
Expand Down Expand Up @@ -1655,6 +1657,20 @@ function OpenCommandPaletteDialog(props: {
},
});

if (activeThread != null) {
actionItems.push({
kind: "action",
value: "action:find-in-chat",
searchTerms: ["find in chat", "find in thread", "search chat", "search messages", "find"],
title: "Find in chat",
icon: <SearchIcon className={ITEM_ICON_CLASS} />,
shortcutCommand: "chat.find",
run: async () => {
openChatFind();
},
});
}

actionItems.push({
kind: "action",
value: "action:add-project",
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/components/chat/ChatFindBar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vite-plus/test";
import {
buildChatFindPattern,
countChatFindOccurrences,
deriveChatFindMatches,
resolveChatFindActiveIndex,
resolveChatFindStartIndex,
stepChatFindIndex,
} from "./ChatFindBar.logic";
import type { MessagesTimelineRow } from "./MessagesTimeline.logic";

function messageRow(id: string, role: "user" | "assistant", text: string): MessagesTimelineRow {
return {
kind: "message",
id,
createdAt: "2026-01-01T00:00:00Z",
message: {
id: id as never,
role,
text,
turnId: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
streaming: false,
},
durationStart: "2026-01-01T00:00:00Z",
showAssistantMeta: false,
showAssistantCopyButton: false,
assistantCopyStreaming: false,
};
}

const foldRow: MessagesTimelineRow = {
kind: "turn-fold",
id: "turn-fold:turn-1",
createdAt: "2026-01-01T00:00:00Z",
turnId: "turn-1" as never,
label: "Worked for 3s on error handling",
expanded: false,
};

const rows: MessagesTimelineRow[] = [
messageRow("user-1", "user", "Fix the Error in the parser"),
foldRow,
messageRow("assistant-1", "assistant", "No error here."),
messageRow("user-2", "user", "still broken"),
messageRow("assistant-2", "assistant", "Found the error; the ERROR was a typo. error fixed."),
];

function find(query: string, options?: Parameters<typeof buildChatFindPattern>[1]) {
return deriveChatFindMatches(rows, buildChatFindPattern(query, options));
}

describe("buildChatFindPattern", () => {
it("returns null for an empty or whitespace query", () => {
expect(buildChatFindPattern("")).toBeNull();
expect(buildChatFindPattern(" ")).toBeNull();
});

it("escapes regex metacharacters", () => {
expect(countChatFindOccurrences("a.b a-b a.b", buildChatFindPattern("a.b")!)).toBe(2);
expect(countChatFindOccurrences("x(1) x(2)", buildChatFindPattern("(1)")!)).toBe(1);
});
});

describe("countChatFindOccurrences", () => {
it("counts case-insensitive, non-overlapping occurrences by default", () => {
expect(countChatFindOccurrences("Error error ERROR", buildChatFindPattern("error")!)).toBe(3);
expect(countChatFindOccurrences("aaaa", buildChatFindPattern("aa")!)).toBe(2);
expect(countChatFindOccurrences("nothing", buildChatFindPattern("error")!)).toBe(0);
});

it("honors case sensitivity", () => {
const pattern = buildChatFindPattern("Error", { caseSensitive: true, wholeWord: false })!;
expect(countChatFindOccurrences("Error error ERROR", pattern)).toBe(1);
});

it("honors whole word matching around punctuation and unicode letters", () => {
const pattern = buildChatFindPattern("error", { caseSensitive: false, wholeWord: true })!;
expect(countChatFindOccurrences("error errors (error) my_error Error.", pattern)).toBe(3);
expect(countChatFindOccurrences("erroré", pattern)).toBe(0);
const dotted = buildChatFindPattern("foo.", { caseSensitive: false, wholeWord: true })!;
expect(countChatFindOccurrences("foo. foo.bar", dotted)).toBe(1);
});
});

describe("deriveChatFindMatches", () => {
it("returns nothing without a pattern", () => {
expect(deriveChatFindMatches(rows, null)).toEqual([]);
});

it("lists one match per occurrence in timeline order and skips non-message rows", () => {
expect(find(" error ")).toEqual([
{ rowId: "user-1", rowIndex: 0, occurrence: 0 },
{ rowId: "assistant-1", rowIndex: 2, occurrence: 0 },
{ rowId: "assistant-2", rowIndex: 4, occurrence: 0 },
{ rowId: "assistant-2", rowIndex: 4, occurrence: 1 },
{ rowId: "assistant-2", rowIndex: 4, occurrence: 2 },
]);
expect(find("Worked for")).toEqual([]);
});

it("applies the match options to every row", () => {
expect(find("ERROR", { caseSensitive: true, wholeWord: false })).toEqual([
{ rowId: "assistant-2", rowIndex: 4, occurrence: 0 },
]);
expect(find("Error", { caseSensitive: false, wholeWord: true })).toHaveLength(5);
});
});

describe("stepChatFindIndex", () => {
it("wraps in both directions and tolerates a single match", () => {
expect(stepChatFindIndex(0, 3, 1)).toBe(1);
expect(stepChatFindIndex(2, 3, 1)).toBe(0);
expect(stepChatFindIndex(0, 3, -1)).toBe(2);
expect(stepChatFindIndex(0, 1, 1)).toBe(0);
expect(stepChatFindIndex(0, 1, -1)).toBe(0);
expect(stepChatFindIndex(4, 0, 1)).toBe(0);
});
});

describe("resolveChatFindActiveIndex", () => {
const matches = find("error");

it("follows the same occurrence when rows shift underneath it", () => {
const previous = matches[3]!;
const shifted = deriveChatFindMatches(
[messageRow("user-0", "user", "hi"), ...rows],
buildChatFindPattern("error"),
);
expect(resolveChatFindActiveIndex(shifted, 3, previous)).toBe(3);
expect(shifted[3]).toEqual({ rowId: "assistant-2", rowIndex: 5, occurrence: 1 });
});

it("clamps when the previous occurrence is gone", () => {
const previous = matches[4]!;
const fewer = deriveChatFindMatches(rows.slice(0, 3), buildChatFindPattern("error"));
expect(resolveChatFindActiveIndex(fewer, 4, previous)).toBe(1);
expect(resolveChatFindActiveIndex([], 4, previous)).toBe(0);
expect(resolveChatFindActiveIndex(matches, -2, null)).toBe(0);
});
});

describe("resolveChatFindStartIndex", () => {
const matches = find("error");

it("starts at the first match at or after the visible row", () => {
expect(resolveChatFindStartIndex(matches, null)).toBe(0);
expect(resolveChatFindStartIndex(matches, 0)).toBe(0);
expect(resolveChatFindStartIndex(matches, 1)).toBe(1);
expect(resolveChatFindStartIndex(matches, 3)).toBe(2);
});

it("falls back to the first match when nothing follows the visible row", () => {
expect(resolveChatFindStartIndex(matches, 10)).toBe(0);
});
});
99 changes: 99 additions & 0 deletions apps/web/src/components/chat/ChatFindBar.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { MessagesTimelineRow } from "./MessagesTimeline.logic";

export interface ChatFindMatch {
readonly rowId: string;
readonly rowIndex: number;
readonly occurrence: number;
}

export function chatFindMatchKey(match: ChatFindMatch): string {
return `${match.rowId}:${match.occurrence}`;
}

export function chatFindSearchableText(row: MessagesTimelineRow): string | null {
if (row.kind !== "message") return null;
const text = row.message.text;
return typeof text === "string" && text.length > 0 ? text : null;
}

export interface ChatFindOptions {
readonly caseSensitive: boolean;
readonly wholeWord: boolean;
}

export const DEFAULT_CHAT_FIND_OPTIONS: ChatFindOptions = {
caseSensitive: false,
wholeWord: false,
};

const WORD_CHARACTER = String.raw`[\p{L}\p{N}_]`;

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function buildChatFindPattern(
query: string,
options: ChatFindOptions = DEFAULT_CHAT_FIND_OPTIONS,
): RegExp | null {
const trimmed = query.trim();
if (trimmed.length === 0) return null;
const escaped = escapeRegExp(trimmed);
const source = options.wholeWord
? `(?<!${WORD_CHARACTER})${escaped}(?!${WORD_CHARACTER})`
: escaped;
return new RegExp(source, options.caseSensitive ? "gu" : "giu");
}

export function countChatFindOccurrences(text: string, pattern: RegExp): number {
let count = 0;
for (const _ of text.matchAll(pattern)) count += 1;
return count;
}

export function deriveChatFindMatches(
rows: ReadonlyArray<MessagesTimelineRow>,
pattern: RegExp | null,
): ChatFindMatch[] {
if (pattern === null) return [];
const matches: ChatFindMatch[] = [];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex];
if (!row) continue;
const text = chatFindSearchableText(row);
if (text === null) continue;
const count = countChatFindOccurrences(text, pattern);
for (let occurrence = 0; occurrence < count; occurrence += 1) {
matches.push({ rowId: row.id, rowIndex, occurrence });
}
}
return matches;
}

export function resolveChatFindStartIndex(
matches: ReadonlyArray<ChatFindMatch>,
fromRowIndex: number | null,
): number {
if (fromRowIndex === null) return 0;
const index = matches.findIndex((match) => match.rowIndex >= fromRowIndex);
return index === -1 ? 0 : index;
}

export function stepChatFindIndex(index: number, count: number, direction: 1 | -1): number {
if (count <= 0) return 0;
return (((index + direction) % count) + count) % count;
}

export function resolveChatFindActiveIndex(
matches: ReadonlyArray<ChatFindMatch>,
previousIndex: number,
previousMatch: ChatFindMatch | null,
): number {
if (matches.length === 0) return 0;
if (previousMatch !== null) {
const key = chatFindMatchKey(previousMatch);
const sameIndex = matches.findIndex((match) => chatFindMatchKey(match) === key);
if (sameIndex !== -1) return sameIndex;
}
return Math.min(Math.max(previousIndex, 0), matches.length - 1);
}
Loading
Loading