diff --git a/server/index.test.ts b/server/index.test.ts index e36571dd42..a355962567 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -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`); diff --git a/server/index.ts b/server/index.ts index cfc8794040..48bd18c32e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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>(); + 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 }; return null; }) .filter((hit): hit is NonNullable => hit !== null); diff --git a/server/message-db.test.ts b/server/message-db.test.ts index c3f4ae5f21..e696d0d6df 100644 --- a/server/message-db.test.ts +++ b/server/message-db.test.ts @@ -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(); diff --git a/server/message-db.ts b/server/message-db.ts index 425838b41a..2f438ca756 100644 --- a/server/message-db.ts +++ b/server/message-db.ts @@ -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. @@ -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 } : {}), + }; }); } diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index fd781cf039..5f83e16b3c 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -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. */ @@ -594,7 +601,7 @@ const MessagesList = memo(function MessagesList({ })(); if (!row) return null; return ( -
+
{newDay && } {row}
@@ -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, @@ -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(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(() => { @@ -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; @@ -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(() => { @@ -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 @@ -874,6 +917,16 @@ export function ChatView({ bot }: { bot: Bot }) { onSubmitEdit={submitEdit} onRegenerate={regenerate} /> + {laterCount > 0 && ( +
+ +
+ )} {provisioning && (
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index f79c55cc97..ccdd7d7335 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -6,30 +6,19 @@ import { Bot as BotIcon, MessageSquare, Search, Users } from "lucide-react"; import { api, useStore, type Bot, type Group } from "@/state/store"; import { rankByName } from "@/lib/palette-rank"; import { cn } from "@/lib/cn"; - -/** One /api/search result: a text message somewhere in a transcript. */ -interface MessageHit { - threadId: string; - messageId: string; - at: number; - role: string; - snippet: string; - name: string; - botId?: string; - groupId?: string; - task?: string; -} +import type { SearchHit } from "@/lib/search-hit"; +import { landOnSearchHit } from "@/lib/focus-message"; type PaletteEntry = | { kind: "bot"; bot: Bot } | { kind: "room"; group: Group } - | { kind: "message"; hit: MessageHit }; + | { kind: "message"; hit: SearchHit }; export function CommandPalette() { const { state, dispatch } = useStore(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); - const [messageHits, setMessageHits] = useState([]); + const [messageHits, setMessageHits] = useState([]); const [cursor, setCursor] = useState(0); const selectedRef = useRef(null); @@ -65,10 +54,13 @@ export function CommandPalette() { setMessageHits([]); return; } + // Results for the previous query must not remain clickable while the + // debounce and request for this query are pending. + setMessageHits([]); let alive = true; const timer = setTimeout(() => { api(`/api/search?q=${encodeURIComponent(q)}&limit=12`) - .then((result: { hits?: MessageHit[] }) => alive && setMessageHits(result.hits ?? [])) + .then((result: { hits?: SearchHit[] }) => alive && setMessageHits(result.hits ?? [])) .catch(() => alive && setMessageHits([])); }, 150); return () => { @@ -96,17 +88,13 @@ export function CommandPalette() { // hits arriving or rows filtering away can strand the cursor past the end const selected = entries.length ? Math.min(cursor, entries.length - 1) : 0; - const activate = (entry: PaletteEntry) => { + const activate = async (entry: PaletteEntry) => { if (entry.kind === "message") { const hit = entry.hit; - const id = hit.botId ?? hit.groupId; - if (!id) return; - dispatch({ type: "select", id }); - // a hit on a non-active task opens THAT task, not whichever one the - // bot happens to have in front (same rule as the sidebar search) - const targetBot = hit.botId ? state.bots.find((b) => b.id === hit.botId) : undefined; - if (targetBot && targetBot.threadId !== hit.threadId) { - dispatch({ type: "switchTask", botId: targetBot.id, threadId: hit.threadId }); + try { + await landOnSearchHit(hit, state, dispatch); + } catch (error) { + dispatch({ type: "error", message: error instanceof Error ? error.message : String(error) }); } } else { dispatch({ type: "select", id: entry.kind === "bot" ? entry.bot.id : entry.group.id }); @@ -129,7 +117,7 @@ export function CommandPalette() { } else if (e.key === "Enter") { e.preventDefault(); const entry = entries[selected]; - if (entry) activate(entry); + if (entry) void activate(entry); } }; @@ -195,7 +183,7 @@ export function CommandPalette() { row( `bot:${bot.id}`, i, - () => activate({ kind: "bot", bot }), + () => void activate({ kind: "bot", bot }), <> {bot.name} @@ -214,7 +202,7 @@ export function CommandPalette() { row( `room:${group.id}`, roomOffset + i, - () => activate({ kind: "room", group }), + () => void activate({ kind: "room", group }), <> {group.name} @@ -227,22 +215,27 @@ export function CommandPalette() {
)} {q && - messageHits.map((hit, i) => - row( + messageHits.map((hit, i) => { + const before = hit.snippet.slice(0, hit.matchStart); + const match = hit.snippet.slice(hit.matchStart, hit.matchStart + hit.matchLength); + const after = hit.snippet.slice(hit.matchStart + hit.matchLength); + return row( `msg:${hit.threadId}:${hit.messageId}`, messageOffset + i, - () => activate({ kind: "message", hit }), + () => void activate({ kind: "message", hit }), <> {hit.name} {hit.task ? · {hit.task} : null} - {hit.snippet} + + {before}{match}{after} + , true, - ), - )} + ); + })}
diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index 416b32590c..54b4610d1e 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -22,9 +22,16 @@ import { GroupCallButton, GroupCallOverlay } from "./GroupCallView"; import { ReactionBar, ReactionChips } from "./Reactions"; import { ApprovalCard } from "./ApprovalCard"; import { cn } from "@/lib/cn"; +import { useFocusMessage } from "@/lib/focus-message"; import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow"; import { showWorkingDots } from "@/lib/turn-tail"; -import { expandWindowStart, resolveTranscriptWindow, tailWindowStart } from "@/lib/transcript-window"; +import { + TRANSCRIPT_WINDOW_SIZE, + expandWindowStart, + focusWindowRange, + resolveTranscriptWindow, + tailWindowStart, +} from "@/lib/transcript-window"; function dayLabel(at: number): string { const d = new Date(at); @@ -116,7 +123,7 @@ const Transcript = memo(function Transcript({ ) : null; if (!row) return null; return ( -
+
{newDay && (
{dayLabel(m.at)} {formatTime(m.at)} @@ -216,20 +223,27 @@ export function GroupView({ group }: { group: Group }) { // the anchored boundary re-tails on a render-phase reset when the room (or // its thread) changes. Working dots below stay on the FULL list's tail. const transcriptKey = `${group.id}:${group.threadId}`; - const [transcriptWindow, setTranscriptWindow] = useState(() => ({ + const [transcriptWindow, setTranscriptWindow] = useState<{ + key: string; + start: number; + end: number | null; + }>(() => ({ key: transcriptKey, start: tailWindowStart(group.messages.length), + end: null, })); if (transcriptWindow.key !== transcriptKey) { - setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(group.messages.length) }); + setTranscriptWindow({ key: transcriptKey, start: tailWindowStart(group.messages.length), end: null }); } const { visible: windowedMessages, hiddenCount, + laterCount, startIndex, + endIndex, } = useMemo( - () => resolveTranscriptWindow(group.messages, transcriptWindow.start), - [group.messages, transcriptWindow.start], + () => resolveTranscriptWindow(group.messages, transcriptWindow.start, TRANSCRIPT_WINDOW_SIZE, transcriptWindow.end), + [group.messages, transcriptWindow.start, transcriptWindow.end], ); const setBottomFollow = useCallback((next: boolean) => { @@ -238,6 +252,20 @@ export function GroupView({ group }: { group: Group }) { }, []); useEffect(() => setBottomFollow(true), [group.id, setBottomFollow]); + + const appliedFocus = useRef(null); + useEffect(() => { + const focus = state.focusMessage; + if (!focus || focus.consumed || focus.threadId !== group.threadId || appliedFocus.current === focus.nonce) return; + const targetIndex = group.messages.findIndex((message) => message.id === focus.messageId); + if (targetIndex < 0) return; + appliedFocus.current = focus.nonce; + const range = focusWindowRange(group.messages.length, targetIndex); + setBottomFollow(false); + setTranscriptWindow({ key: transcriptKey, start: range.start, end: range.end }); + }, [group.messages, group.threadId, setBottomFollow, state.focusMessage, transcriptKey]); + useFocusMessage(group.threadId, group.messages.length > 0); + useEffect(() => setBulletinDraft(group.bulletin), [group.id, group.bulletin]); // deps track the FULL messages.length, so expanding the window (which only // changes windowedMessages) can never re-trigger this bottom scrollTo @@ -258,7 +286,7 @@ export function GroupView({ group }: { group: Group }) { // 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; @@ -270,6 +298,12 @@ export function GroupView({ group }: { group: Group }) { previousScrollTop.current = el.scrollTop; }, [transcriptWindow.start]); + const showLater = () => { + setBottomFollow(false); + const nextEnd = Math.min(group.messages.length, endIndex + TRANSCRIPT_WINDOW_SIZE); + setTranscriptWindow((w) => ({ ...w, end: nextEnd >= group.messages.length ? null : nextEnd })); + }; + const atEnd = () => { const el = scrollRef.current; return !el || el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_FOLLOW_THRESHOLD; @@ -427,6 +461,16 @@ export function GroupView({ group }: { group: Group }) {
)} + {laterCount > 0 && ( +
+ +
+ )} {speaker && showWorkingDots(true, streaming, group.messages.at(-1), speaker.id) && ( <> @@ -452,7 +496,10 @@ export function GroupView({ group }: { group: Group }) { + ); + })} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d67fc9471c..babcffe492 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -28,24 +28,13 @@ import { } from "lucide-react"; import { api, useStore, formatTime, visibleMessages, type Bot, type Group } from "@/state/store"; -/** One /api/search result: a text message somewhere in a transcript. */ -interface MessageHit { - threadId: string; - messageId: string; - at: number; - role: string; - snippet: string; - name: string; - botId?: string; - groupId?: string; - task?: string; -} import { MausAvatar, InitialsAvatar } from "./Avatar"; import { stateForBot } from "@/lib/mascot"; import { useUpdaterState } from "@/lib/updater"; import { cn } from "@/lib/cn"; import { downloadAllBots } from "@/lib/team-files"; import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { MIN_QUERY, SearchResults } from "./SearchResults"; import { TeamLibraryPanel, type TeamImportResult } from "./TeamLibraryPanel"; import { RenameTitle } from "./RenameTitle"; @@ -764,7 +753,6 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void restoreBot?: { id: string; name: string }; } | null>(null); const [query, setQuery] = useState(""); - const [messageHits, setMessageHits] = useState([]); // Esc closes the drawer, mirroring ApiKeys.tsx:75-85. Bound only while the // drawer is open — on mobile, exactly when a bot/room context menu or the @@ -887,24 +875,8 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void const q = query.trim().toLowerCase(); // Message search rides the same box as the name filter: names match - // instantly from local state; transcript hits arrive from /api/search a - // debounce later. A stale response for an outdated query is dropped. - useEffect(() => { - if (!q) { - setMessageHits([]); - return; - } - let alive = true; - const timer = setTimeout(() => { - api(`/api/search?q=${encodeURIComponent(q)}&limit=12`) - .then((result: { hits?: MessageHit[] }) => alive && setMessageHits(result.hits ?? [])) - .catch(() => alive && setMessageHits([])); - }, 250); - return () => { - alive = false; - clearTimeout(timer); - }; - }, [q]); + // instantly from local state; transcript hits are the SearchResults + // section below the list (debounced, lands on the message). const matchingBots = state.bots .filter((b) => !b.hidden) @@ -1041,7 +1013,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setQuery("")} placeholder="Search" - aria-label="Search bots" + aria-label="Search bots and messages" className="w-full bg-transparent text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" />
@@ -1050,7 +1022,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void {/* Bot list */}
- {!chiefBot && visibleBots.length === 0 && visibleGroups.length === 0 && messageHits.length === 0 && q && ( + {!chiefBot && visibleBots.length === 0 && visibleGroups.length === 0 && q && q.length < MIN_QUERY && (
Nothing matches “{query}”
)} {chiefBot && ( @@ -1075,35 +1047,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void archiveDisabled={activeBotCount <= 1} /> ))} - {q && messageHits.length > 0 && ( -
-
- In conversations -
- {messageHits.map((hit) => ( - - ))} -
- )} + setQuery("")} />
diff --git a/src/lib/focus-message.ts b/src/lib/focus-message.ts new file mode 100644 index 0000000000..692a36da5b --- /dev/null +++ b/src/lib/focus-message.ts @@ -0,0 +1,75 @@ +// Landing on a message: after a search hit, scroll the row into view and +// flash it. Rows are wrapped in `display: contents` (no box of their own), +// so the wrapper carries data-mid and its last child — the bubble/chip, +// after any day separator — is what gets scrolled and highlighted. +import { useEffect } from "react"; +import { api, useStore, type Action, type AppState } from "@/state/store"; +import type { SearchHit } from "@/lib/search-hit"; + +const FLASH_CLASSES = ["ring-2", "ring-accent/70", "rounded-2xl", "transition-shadow"]; + +/** Select and prepare the exact conversation represented by a search hit. */ +export async function landOnSearchHit( + hit: SearchHit, + state: Pick, + dispatch: React.Dispatch, +): Promise { + const ownerId = hit.botId ?? hit.groupId; + const bot = hit.botId ? state.bots.find((candidate) => candidate.id === hit.botId) : undefined; + const group = hit.groupId ? state.groups.find((candidate) => candidate.id === hit.groupId) : undefined; + if (!ownerId || (!bot && !group)) throw new Error("That conversation is no longer available."); + + dispatch({ type: "select", id: ownerId }); + if (bot && bot.threadId !== hit.threadId) { + const result = await api(`/api/bots/${bot.id}/tasks/${hit.threadId}`, { method: "POST" }); + if (result?.bot) dispatch({ type: "taskSwitched", bot: result.bot }); + } + if (bot && !hit.onActivePath) { + const branch = await api(`/api/bots/${bot.id}/active-branch`, { + method: "POST", + body: JSON.stringify({ messageId: hit.messageId }), + }); + if (branch?.activeLeafId) { + dispatch({ type: "threadActive", threadId: hit.threadId, activeLeafId: branch.activeLeafId }); + } + } + dispatch({ type: "focusMessage", threadId: hit.threadId, messageId: hit.messageId }); +} + +export function useFocusMessage(threadId: string, ready: boolean) { + const { state, dispatch } = useStore(); + const focus = state.focusMessage; + useEffect(() => { + if (!focus || focus.consumed || focus.threadId !== threadId || !ready) return; + // messages may land a tick after the task switch; try briefly + let tries = 0; + let cancelled = false; + let retryTimer: ReturnType | null = null; + let flashTimer: ReturnType | null = null; + let target: HTMLElement | null = null; + const attempt = () => { + if (cancelled) return; + const wrapper = document.querySelector(`[data-mid="${CSS.escape(focus.messageId)}"]`); + target = wrapper?.lastElementChild as HTMLElement | null; + if (!target) { + if (tries++ < 20) retryTimer = setTimeout(attempt, 100); + return; + } + const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + target.scrollIntoView({ block: "center", behavior: reducedMotion ? "auto" : "smooth" }); + target.classList.add(...FLASH_CLASSES); + // Consume only after the target is mounted and the flash has begun. + // `consumed` is intentionally not an effect dependency, so this active + // flash survives the bookkeeping update while future remounts ignore it. + dispatch({ type: "focusMessageConsumed", nonce: focus.nonce }); + flashTimer = setTimeout(() => target?.classList.remove(...FLASH_CLASSES), 1800); + }; + attempt(); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + if (flashTimer) clearTimeout(flashTimer); + target?.classList.remove(...FLASH_CLASSES); + }; + }, [dispatch, focus?.nonce, focus?.threadId, focus?.messageId, threadId, ready]); +} diff --git a/src/lib/search-hit.ts b/src/lib/search-hit.ts new file mode 100644 index 0000000000..314a8605f7 --- /dev/null +++ b/src/lib/search-hit.ts @@ -0,0 +1,17 @@ +/** One /api/search hit, resolved to the bot or room that owns it. */ +export interface SearchHit { + botId?: string; + groupId?: string; + name: string; + threadId: string; + task?: string; + messageId: string; + role: string; + kind: string; + from?: string; + at: number; + snippet: string; + matchStart: number; + matchLength: number; + onActivePath: boolean; +} diff --git a/src/lib/transcript-window.test.ts b/src/lib/transcript-window.test.ts index 2be0b3a1ef..f76ac9f10f 100644 --- a/src/lib/transcript-window.test.ts +++ b/src/lib/transcript-window.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { TRANSCRIPT_WINDOW_SIZE, expandWindowStart, + focusWindowRange, resolveTranscriptWindow, tailWindowStart, } from "./transcript-window"; @@ -47,6 +48,7 @@ describe("resolveTranscriptWindow", () => { expect(result.visible).toHaveLength(10); expect(result.hiddenCount).toBe(0); expect(result.startIndex).toBe(0); + expect(result.laterCount).toBe(0); }); it("windows a long thread to its tail", () => { @@ -101,4 +103,34 @@ describe("resolveTranscriptWindow", () => { expect(result.visible).toHaveLength(4); expect(result.hiddenCount).toBe(6); }); + + it("keeps a finite search-focus window instead of mounting through the tail", () => { + const result = resolveTranscriptWindow(thread(1_000), 440, TRANSCRIPT_WINDOW_SIZE, 560); + expect(result.visible).toHaveLength(TRANSCRIPT_WINDOW_SIZE); + expect(result.visible[0]).toBe(440); + expect(result.visible.at(-1)).toBe(559); + expect(result.hiddenCount).toBe(440); + expect(result.laterCount).toBe(440); + expect(result.endIndex).toBe(560); + }); + + it("falls back to the tail if a finite window becomes invalid after a rewind", () => { + const result = resolveTranscriptWindow(thread(100), 440, TRANSCRIPT_WINDOW_SIZE, 560); + expect(result.visible[0]).toBe(0); + expect(result.visible.at(-1)).toBe(99); + expect(result.laterCount).toBe(0); + }); +}); + +describe("focusWindowRange", () => { + it.each([10, 500, 990])("contains target %i in a bounded window", (target) => { + const range = focusWindowRange(1_000, target); + expect(target).toBeGreaterThanOrEqual(range.start); + expect(target).toBeLessThan(range.end); + expect(range.end - range.start).toBeLessThanOrEqual(TRANSCRIPT_WINDOW_SIZE); + }); + + it("uses the full short transcript", () => { + expect(focusWindowRange(20, 10)).toEqual({ start: 0, end: 20 }); + }); }); diff --git a/src/lib/transcript-window.ts b/src/lib/transcript-window.ts index edbc00418c..c557729ea8 100644 --- a/src/lib/transcript-window.ts +++ b/src/lib/transcript-window.ts @@ -8,9 +8,18 @@ export interface TranscriptWindow { visible: T[]; /** Messages hidden before the window — the pill's "(X more)" count. */ hiddenCount: number; + /** Messages hidden after a finite search-focus window. */ + laterCount: number; /** The boundary actually applied after clamping; expand steps from this, * not from the stored value, so a clamped window expands predictably. */ startIndex: number; + /** Exclusive end boundary, or the current list length for a tail window. */ + endIndex: number; +} + +export interface TranscriptWindowRange { + start: number; + end: number; } /** Boundary for a fresh window: the last `size` messages. */ @@ -23,6 +32,20 @@ export function expandWindowStart(startIndex: number, size: number = TRANSCRIPT_ return Math.max(0, startIndex - size); } +/** A bounded window containing a search target. Keeping this finite avoids + * mounting an entire old transcript merely to land on one result. */ +export function focusWindowRange( + total: number, + targetIndex: number, + size: number = TRANSCRIPT_WINDOW_SIZE, +): TranscriptWindowRange { + const safeTotal = Math.max(0, total); + const safeSize = Math.max(1, size); + const target = Math.max(0, Math.min(targetIndex, Math.max(0, safeTotal - 1))); + const start = Math.max(0, Math.min(target - Math.floor(safeSize / 2), Math.max(0, safeTotal - safeSize))); + return { start, end: Math.min(safeTotal, start + safeSize) }; +} + /** Resolve a stored boundary against the current list. The boundary is * anchored — appends grow the window instead of sliding it, so rows the * reader is looking at never drop out from under them. Anchoring means a @@ -33,8 +56,20 @@ export function resolveTranscriptWindow( messages: readonly T[], startIndex: number, size: number = TRANSCRIPT_WINDOW_SIZE, + endIndex: number | null = null, ): TranscriptWindow { + const requestedEnd = endIndex === null ? messages.length : Math.max(0, Math.min(messages.length, endIndex)); + const invalidFiniteWindow = endIndex !== null && startIndex >= requestedEnd; const start = - startIndex >= messages.length ? tailWindowStart(messages.length, size) : Math.max(0, startIndex); - return { visible: messages.slice(start), hiddenCount: start, startIndex: start }; + startIndex >= messages.length || invalidFiniteWindow + ? tailWindowStart(messages.length, size) + : Math.max(0, startIndex); + const end = invalidFiniteWindow ? messages.length : Math.max(start, requestedEnd); + return { + visible: messages.slice(start, end), + hiddenCount: start, + laterCount: messages.length - end, + startIndex: start, + endIndex: end, + }; } diff --git a/src/state/store.tsx b/src/state/store.tsx index 4189d652b7..3bcf9dab5b 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -237,7 +237,7 @@ export type AppSettingsSection = | "voice" | "computer"; -interface AppState { +export interface AppState { bots: Bot[]; groups: Group[]; instances: InstanceInfo[]; @@ -259,6 +259,9 @@ interface AppState { screens: Record; /** bots whose cloud computer is being provisioned */ provisioning: Record; + /** a search hit to scroll to once its thread is on screen; nonce lets the + * same message be focused twice in a row */ + focusMessage: { threadId: string; messageId: string; nonce: number; consumed: boolean } | null; connected: boolean; error: string | null; mascotMotion: { @@ -268,7 +271,7 @@ interface AppState { } | null; } -type Action = +export type Action = | { type: "hydrate"; bots: Bot[]; groups: Group[] } | { type: "showRoutines" } | { type: "routinesHydrated"; routines: Routine[]; runs: RoutineRun[] } @@ -339,6 +342,8 @@ type Action = | { type: "toggleSettings"; open?: boolean } | { type: "togglePlugins"; open?: boolean } | { type: "toggleComputer"; open?: boolean } + | { type: "focusMessage"; threadId: string; messageId: string } + | { type: "focusMessageConsumed"; nonce: number } | { type: "toggleAppSettings"; open?: boolean; section?: AppSettingsSection } | { type: "updateBot"; @@ -664,6 +669,19 @@ function reducer(state: AppState, action: Action): AppState { } case "togglePlugins": return { ...state, pluginsOpen: action.open ?? !state.pluginsOpen }; + case "focusMessage": + return { + ...state, + focusMessage: { + threadId: action.threadId, + messageId: action.messageId, + nonce: (state.focusMessage?.nonce ?? 0) + 1, + consumed: false, + }, + }; + case "focusMessageConsumed": + if (!state.focusMessage || state.focusMessage.nonce !== action.nonce) return state; + return { ...state, focusMessage: { ...state.focusMessage, consumed: true } }; case "toggleComputer": { const open = action.open ?? !state.computerOpen; return { @@ -795,6 +813,7 @@ const initialState: AppState = { appSettingsSection: "general", screens: {}, provisioning: {}, + focusMessage: null, connected: false, error: null, mascotMotion: null,