Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 11 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2148,15 +2148,24 @@ 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);
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.
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
50 changes: 42 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,48 @@ 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,
matchLength: folded.length,
...(row.from_name ? { from: row.from_name } : {}),
Comment thread
milind-soni marked this conversation as resolved.
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.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