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
9 changes: 8 additions & 1 deletion server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,15 @@ describe("harness HTTP API", () => {
const hits = await api("GET", "/api/search?q=nice%20to%20meet");
expect(hits.status).toBe(200);
const hit = hits.body.hits.find((h: { botId?: string }) => h.botId === bot.id);
expect(hit).toMatchObject({ botId: bot.id, threadId: bot.threadId, name: bot.name });
expect(hit).toMatchObject({
botId: bot.id,
threadId: bot.threadId,
name: bot.name,
kind: "text",
onActivePath: true,
});
expect(hit.snippet.toLowerCase()).toContain("nice to meet");
expect(hit.snippet.slice(hit.matchStart, hit.matchStart + hit.matchLength).toLowerCase()).toBe("nice to meet");
expect((await api("GET", "/api/search?q=")).body.hits).toEqual([]);

const markdown = await fetch(`${BASE}/api/threads/${bot.threadId}/export`);
Expand Down
14 changes: 12 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2148,15 +2148,25 @@ const server = createServer(async (req, res) => {
const q = url.searchParams.get("q") ?? "";
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit ? Math.min(Math.max(Number(rawLimit) || 0, 1), 100) : 40;
// whether each hit sits on its thread's visible branch — a click on
// one that does not has to switch versions first (and only then)
const activePaths = new Map<string, Set<string>>();
const onActivePath = (threadId: string, messageId: string) => {
let ids = activePaths.get(threadId);
if (!ids) activePaths.set(threadId, (ids = new Set(store.activePath(threadId).map((m) => m.id))));
return ids.has(messageId);
};
const hits = searchMessages(q, limit)
.map((hit) => {
const bot = store.botByThread(hit.threadId);
const group = bot ? undefined : store.groupByThread(hit.threadId);
if (!bot && !group) return null;
const active = onActivePath(hit.threadId, hit.messageId);
if (bot) {
const task = store.taskByThread(bot.id, hit.threadId);
return { ...hit, botId: bot.id, name: bot.name, task: task?.title };
return { ...hit, botId: bot.id, name: bot.name, task: task?.title, onActivePath: active };
}
if (group) return { ...hit, groupId: group.id, name: group.name };
if (group) return { ...hit, groupId: group.id, name: group.name, onActivePath: active };
Comment thread
milind-soni marked this conversation as resolved.
return null;
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
Expand Down
19 changes: 19 additions & 0 deletions server/message-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ describe("message-db", () => {
expect(searchMessages("")).toEqual([]);
});

it("search reports the match offset for highlighting, and finds activity chips by tool name", () => {
insertMessage("t7", msg("m1", "please\n\n run the migration now"));
insertMessage("t7", { ...msg("m2", ""), kind: "activity", role: "bot", tool: { name: "Bash: alembic upgrade head", ok: true } } as Message);
insertMessage("t7", { ...msg("m3", "we spoke about it"), from: { botId: "b2", name: "Scout", color: "green" } } as Message);

const text = searchMessages("the migration")[0];
expect(text.messageId).toBe("m1");
// whitespace folded in the snippet, offset points at the folded match
expect(text.snippet.slice(text.matchStart, text.matchStart + text.matchLength)).toBe("the migration");

// "which bot ran that migration" — the tool name is searchable
const chip = searchMessages("alembic")[0];
expect(chip).toMatchObject({ messageId: "m2", kind: "activity" });
expect(chip.snippet).toContain("alembic upgrade head");

// room attribution rides along
expect(searchMessages("spoke")[0].from).toBe("Scout");
});

it("Store round-trips branching through the DB across a restart", () => {
const store = new Store(selection);
const bot = store.createBot();
Expand Down
51 changes: 43 additions & 8 deletions server/message-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,14 @@ export interface SearchHit {
messageId: string;
at: number;
role: string;
kind: string;
/** the matched text, trimmed to a window around the first hit */
snippet: string;
/** where the match sits inside `snippet`, for highlighting */
matchStart: number;
matchLength: number;
/** room messages: which member said it */
from?: string;
}

/** Case-insensitive substring search over text messages, newest first.
Expand All @@ -184,20 +190,49 @@ export function searchMessages(query: string, limit = 40): SearchHit[] {
if (!needle) return [];
// escape LIKE wildcards so a literal % or _ in the query stays literal
const pattern = `%${needle.replace(/([\\%_])/g, "\\$1")}%`;
// text messages by their text; activity chips by the tool name — "which
// bot ran that migration" is a tool-name question. The chip's name lives
// in the row's json; a JSON1 extract keeps this one query.
const rows = db()
.prepare(
"SELECT thread_id, id, at, role, text FROM messages " +
"WHERE kind = 'text' AND text IS NOT NULL AND lower(text) LIKE ? ESCAPE '\\' " +
"SELECT thread_id, id, at, role, kind, text, json_extract(json, '$.tool.name') AS tool_name, json_extract(json, '$.from.name') AS from_name FROM messages " +
"WHERE (kind = 'text' AND text IS NOT NULL AND lower(text) LIKE ? ESCAPE '\\') " +
" OR (kind = 'activity' AND tool_name IS NOT NULL AND lower(tool_name) LIKE ? ESCAPE '\\') " +
"ORDER BY at DESC LIMIT ?",
)
.all(pattern, limit) as Array<{ thread_id: string; id: string; at: number; role: string; text: string }>;
.all(pattern, pattern, limit) as Array<{
thread_id: string;
id: string;
at: number;
role: string;
kind: string;
text: string | null;
tool_name: string | null;
from_name: string | null;
}>;
return rows.map((row) => {
const hitAt = row.text.toLowerCase().indexOf(needle);
const haystack = row.kind === "activity" ? (row.tool_name ?? "") : (row.text ?? "");
const hitAt = Math.max(0, haystack.toLowerCase().indexOf(needle));
const start = Math.max(0, hitAt - 60);
const end = Math.min(row.text.length, hitAt + needle.length + 90);
const snippet =
(start > 0 ? "…" : "") + row.text.slice(start, end).replace(/\s+/g, " ").trim() + (end < row.text.length ? "…" : "");
return { threadId: row.thread_id, messageId: row.id, at: row.at, role: row.role, snippet };
const end = Math.min(haystack.length, hitAt + needle.length + 90);
const head = start > 0 ? "…" : "";
const body = haystack.slice(start, end).replace(/\s+/g, " ").trim();
const snippet = head + body + (end < haystack.length ? "…" : "");
// whitespace folding can shift the offset; find the match again inside
const folded = needle.replace(/\s+/g, " ");
const matchStart = snippet.toLowerCase().indexOf(folded);
return {
threadId: row.thread_id,
messageId: row.id,
at: row.at,
role: row.role,
kind: row.kind,
snippet,
matchStart: matchStart < 0 ? head.length : matchStart,
// A defensive fallback must not mark arbitrary snippet text as the hit.
matchLength: matchStart < 0 ? 0 : folded.length,
...(row.from_name ? { from: row.from_name } : {}),
Comment thread
milind-soni marked this conversation as resolved.
};
});
}

Expand Down
67 changes: 60 additions & 7 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,16 @@ import { ReactionBar, ReactionChips } from "./Reactions";
import { SpeakButton } from "./SpeakButton";
import { CallButton, CallOverlay } from "./CallView";
import { cn } from "@/lib/cn";
import { useFocusMessage } from "@/lib/focus-message";
import { webhookMessageView } from "@/lib/webhook-message";
import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow";
import { expandWindowStart, resolveTranscriptWindow, tailWindowStart } from "@/lib/transcript-window";
import {
TRANSCRIPT_WINDOW_SIZE,
expandWindowStart,
focusWindowRange,
resolveTranscriptWindow,
tailWindowStart,
} from "@/lib/transcript-window";

/** Long user messages collapse behind a fade so pasted walls of text don't
* bury the conversation; bots get full markdown. */
Expand Down Expand Up @@ -594,7 +601,7 @@ const MessagesList = memo(function MessagesList({
})();
if (!row) return null;
return (
<div key={m.id} className="contents">
<div key={m.id} className="contents" data-mid={m.id}>
{newDay && <DaySeparator at={m.at} />}
{row}
</div>
Expand Down Expand Up @@ -623,18 +630,28 @@ export function ChatView({ bot }: { bot: Bot }) {
// never flashes into the new one. Everything derived below (lastBotTextId,
// lastUserMessage, working dots) stays computed from the FULL list.
const transcriptKey = `${bot.id}:${bot.threadId}`;
const [transcriptWindow, setTranscriptWindow] = useState(() => ({
const [transcriptWindow, setTranscriptWindow] = useState<{
key: string;
start: number;
end: number | null;
}>(() => ({
key: transcriptKey,
start: tailWindowStart(messages.length),
end: null,
}));
if (transcriptWindow.key !== transcriptKey) {
setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(messages.length) });
setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(messages.length), end: null });
}
const {
visible: windowedMessages,
hiddenCount,
laterCount,
startIndex,
} = useMemo(() => resolveTranscriptWindow(messages, transcriptWindow.start), [messages, transcriptWindow.start]);
endIndex,
} = useMemo(
() => resolveTranscriptWindow(messages, transcriptWindow.start, TRANSCRIPT_WINDOW_SIZE, transcriptWindow.end),
[messages, transcriptWindow.start, transcriptWindow.end],
);

const lastBotTextId = useMemo(
() => [...messages].reverse().find((m) => m.role === "bot" && m.kind === "text")?.id,
Expand Down Expand Up @@ -682,6 +699,23 @@ export function ChatView({ bot }: { bot: Bot }) {
}, []);

useEffect(() => setBottomFollow(true), [bot.id, setBottomFollow]);

// A search result may be hundreds of rows before the mounted tail. Open a
// bounded window around it first; useFocusMessage then scrolls and flashes
// the row after React commits that window.
const appliedFocus = useRef<number | null>(null);
useEffect(() => {
const focus = state.focusMessage;
if (!focus || focus.consumed || focus.threadId !== bot.threadId || appliedFocus.current === focus.nonce) return;
const targetIndex = messages.findIndex((message) => message.id === focus.messageId);
if (targetIndex < 0) return;
appliedFocus.current = focus.nonce;
const range = focusWindowRange(messages.length, targetIndex);
setBottomFollow(false);
setTranscriptWindow({ key: transcriptKey, start: range.start, end: range.end });
}, [bot.threadId, messages, setBottomFollow, state.focusMessage, transcriptKey]);
useFocusMessage(bot.threadId, messages.length > 0);

// deps track the FULL messages.length, so expanding the window (which only
// changes windowedMessages) can never re-trigger this bottom scrollTo
useEffect(() => {
Expand All @@ -701,7 +735,7 @@ export function ChatView({ bot }: { bot: Bot }) {
// event pin the viewport back to the bottom
setBottomFollow(false);
const start = expandWindowStart(startIndex);
setTranscriptWindow((w) => ({ key: w.key, start }));
setTranscriptWindow((w) => ({ ...w, start }));
};
useLayoutEffect(() => {
const el = scrollRef.current;
Expand All @@ -713,6 +747,12 @@ export function ChatView({ bot }: { bot: Bot }) {
previousScrollTop.current = el.scrollTop;
}, [transcriptWindow.start]);

const showLater = () => {
setBottomFollow(false);
const nextEnd = Math.min(messages.length, endIndex + TRANSCRIPT_WINDOW_SIZE);
setTranscriptWindow((w) => ({ ...w, end: nextEnd >= messages.length ? null : nextEnd }));
};

// keyboard is a scroll gesture too (upstream lesson): PageUp/Home break
// follow like an upward wheel; the at-end onScroll check re-arms it
useEffect(() => {
Expand All @@ -731,7 +771,10 @@ export function ChatView({ bot }: { bot: Bot }) {
};
const jumpToLatest = () => {
setBottomFollow(true);
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(messages.length), end: null });
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
});
};

// on Windows the frameless window's min/max/close overlay sits at the
Expand Down Expand Up @@ -874,6 +917,16 @@ export function ChatView({ bot }: { bot: Bot }) {
onSubmitEdit={submitEdit}
onRegenerate={regenerate}
/>
{laterCount > 0 && (
<div className="flex justify-center">
<button
onClick={showLater}
className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink"
>
Show later messages ({laterCount} more)
</button>
</div>
)}
{provisioning && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-full border border-hairline/40 bg-panel px-3 py-1.5 text-[13px] text-ink-secondary">
Expand Down
Loading
Loading