diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index b053d480..b40683f0 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -15,6 +15,7 @@ import { import { useT } from "../../i18n"; import { day } from "../../lib/format"; import type { Bot } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { SeenSince } from "../../ui/motion"; @@ -23,11 +24,14 @@ import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; import { NoticeRow } from "./NoticeRow"; -import { chatRows } from "./rows"; +import { chatRows, rowHas } from "./rows"; import { useChat } from "./useChat"; import { useFiles } from "./useFiles"; import { useSharedSound } from "./useSharedSound"; +/** How long a search result stays marked (`search.css`). */ +const FOCUS_MS = 2400; + /** Distance from the bottom that still counts as "reading the latest". */ const STICKY_PX = 80; @@ -36,9 +40,13 @@ const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files" /** Skips the renders of the view around it; the text being written re-renders only the last run. */ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { const t = useT(); - const chat = useChat(bot.id); + const focus = useApp((state) => (state.focus?.botId === bot.id ? state.focus.itemId : null)); + const chat = useChat(bot.id, focus); const files = useFiles(); const scroller = useRef(null); + const listRef = useRef(null); + /** The search result already shown, so it is not scrolled to again. */ + const shownFocus = useRef(null); const atEnd = useRef(true); /** Distance from the bottom to keep while older items load above. */ const anchor = useRef(null); @@ -111,6 +119,23 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto /> ); }); + // A search result opened the chat: bring its row into view, marked. + useLayoutEffect(() => { + if (!focus || shownFocus.current === focus) { + return; + } + const row = listRef.current?.children[rows.findIndex((each) => rowHas(each, focus))]; + if (!row) { + return; + } + shownFocus.current = focus; + atEnd.current = false; + row.scrollIntoView?.({ block: "center" }); + row.classList.add("search-focus"); + // As long as the band's animation, also when it does not move. + setTimeout(() => row.classList.remove("search-focus"), FOCUS_MS); + }, [focus, rows]); + if (live && !openRun) { list.push(); } @@ -177,7 +202,11 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto )} -
    +
      {list}
    diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 32cebe7f..ae8384be 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -60,6 +60,18 @@ export function chatRows(items: ChatItem[]): Row[] { return rows; } +/** Whether the row shows the chat item `id`. */ +export function rowHas(row: Row, id: string): boolean { + switch (row.kind) { + case "day": + return false; + case "run": + return row.items.some((item) => item.id === id); + default: + return row.item.id === id; + } +} + /** Tool calls that run together as lines; shared files are cards of their own. */ function isLine(item: ChatItem | undefined): boolean { return item?.body.kind === "tool" && !isSharedFiles(item); diff --git a/app/src/features/chat/useChat.ts b/app/src/features/chat/useChat.ts index db18d75a..b8bda927 100644 --- a/app/src/features/chat/useChat.ts +++ b/app/src/features/chat/useChat.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { errorText } from "../../lib/api"; -import type { BotId, ChatItem } from "../../lib/protocol.gen"; +import type { BotId, ChatItem, ChatItemId } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; const PAGE = 50; @@ -44,7 +44,8 @@ function merge(history: ChatItem[], live: ChatItem[]): ChatItem[] { return items; } -export function useChat(botId: BotId) { +/** With `focus`, the chat loads from that item to the newest (spec 8.8). */ +export function useChat(botId: BotId, focus: ChatItemId | null = null) { const api = useApi(); const open = useApp((state) => state.connection.kind === "open"); const [page, setPage] = useState(EMPTY); @@ -71,13 +72,19 @@ export function useChat(botId: BotId) { setPage((current) => ({ ...current, draft: current.draft + text })); } }); - api.call("chat.history", { botId, limit: PAGE }).then( + const newest = () => api.call("chat.history", { botId, limit: PAGE }); + // An item that is gone opens the chat as usual. + const first = focus + ? api.call("chat.history", { botId, until: focus }).catch(newest) + : newest(); + first.then( (history) => { if (alive) { setPage((current) => ({ ...current, items: merge(history, current.items), - complete: history.length < PAGE, + // From an item, older ones may still be there. + complete: !focus && history.length < PAGE, loading: false, })); } @@ -92,7 +99,7 @@ export function useChat(botId: BotId) { alive = false; unsubscribe(); }; - }, [api, open, botId]); + }, [api, open, botId, focus]); const oldest = page.items[0]?.id; const loadOlder = useCallback(async () => { diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 23455007..17644684 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,11 +1,4 @@ -import { - ChevronDown, - ChevronsDownUp, - ChevronsUpDown, - MessageCircleQuestion, - Pause, - Plus, -} from "lucide-react"; +import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react"; import { memo, useCallback, useState } from "react"; import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; @@ -14,7 +7,6 @@ import { when } from "../../lib/format"; import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { activityOf, botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; -import { openQuestions } from "../../store/questions"; import { isUnread, unreadIn } from "../../store/seen"; import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; @@ -27,13 +19,14 @@ import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed"; +import { QuestionsEntry, row, SearchEntry } from "./SidebarPages"; /** Crews as sections and their bots as conversations (spec 15.1). */ export function Sidebar() { const t = useT(); const words = t.crews.sidebar; const crews = useApp(useShallow(crewList)); - const overview = useApp((state) => state.selectedCrewId === null && !state.questionBox); + const overview = useApp((state) => state.selectedCrewId === null && !state.page); const selectCrew = useApp((state) => state.selectCrew); const collapsed = useCollapsedSet(); const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id)); @@ -68,6 +61,7 @@ export function Sidebar() { onClick={() => setCreating(true)} /> +
      {crews.map((crew) => ( @@ -81,42 +75,6 @@ export function Sidebar() { ); } -const row = - "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; - -/** The question box (spec 23.6), with how many questions wait. */ -function QuestionsEntry() { - const t = useT(); - const words = t.questions.box; - const count = useApp((state) => openQuestions(state).length); - const open = useApp((state) => state.questionBox); - const openBox = useApp((state) => state.openQuestionBox); - return ( -
      - -
      - ); -} - function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); diff --git a/app/src/features/crews/SidebarPages.tsx b/app/src/features/crews/SidebarPages.tsx new file mode 100644 index 00000000..2eb4945d --- /dev/null +++ b/app/src/features/crews/SidebarPages.tsx @@ -0,0 +1,66 @@ +// The pages at the top of the sidebar: the search (spec 8.8) and the +// question box (spec 23.6). + +import { MessageCircleQuestion, Search } from "lucide-react"; +import { useT } from "../../i18n"; +import { useApp } from "../../store/context"; +import { openQuestions } from "../../store/questions"; + +/** A row of the sidebar, as the conversations are. */ +export const row = + "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; + +/** The search (spec 8.8). */ +export function SearchEntry() { + const t = useT(); + const open = useApp((state) => state.page === "search"); + const openPage = useApp((state) => state.openPage); + return ( +
      + +
      + ); +} + +/** The question box (spec 23.6), with how many questions wait. */ +export function QuestionsEntry() { + const t = useT(); + const words = t.questions.box; + const count = useApp((state) => openQuestions(state).length); + const open = useApp((state) => state.page === "questions"); + const openPage = useApp((state) => state.openPage); + return ( +
      + +
      + ); +} diff --git a/app/src/features/search/SearchPage.tsx b/app/src/features/search/SearchPage.tsx new file mode 100644 index 00000000..c85d698e --- /dev/null +++ b/app/src/features/search/SearchPage.tsx @@ -0,0 +1,175 @@ +// Searching the chats (spec 8.8): a field, the crew to look in, and what +// was found as the owner types, newest first. A result opens its bot's +// chat at that point. + +import { LoaderCircle, Search } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import type { CrewId, SearchHit } from "../../lib/protocol.gen"; +import { crewList } from "../../store/app"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Select } from "../../ui/Select"; +import { SearchResult } from "./SearchResult"; + +const PAGE = 30; +/** Quiet time after a key before searching. */ +const TYPING_MS = 250; + +/** What the owner searched, kept while the app is open. */ +const kept = { query: "", crew: "" as CrewId | "" }; + +interface Found { + hits: SearchHit[]; + /** More may be older than the last one. */ + more: boolean; + loading: boolean; + error: string | null; +} + +const NONE: Found = { hits: [], more: false, loading: false, error: null }; + +/** Letters and digits in the query: fewer than 2 are not searched. */ +const enough = (query: string) => query.replace(/[^\p{L}\p{N}]/gu, "").length >= 2; + +export function SearchPage() { + const t = useT(); + const words = t.search; + const api = useApi(); + const crews = useApp(useShallow(crewList)); + const [query, setQuery] = useState(kept.query); + const [crew, setCrew] = useState( + kept.crew && crews.some((c) => c.id === kept.crew) ? kept.crew : "", + ); + const [found, setFound] = useState(NONE); + /** Which search the shown results are for; older answers are dropped. */ + const latest = useRef(0); + const fieldId = useId(); + + kept.query = query; + kept.crew = crew; + const trimmed = query.trim(); + + useEffect(() => { + const ask = ++latest.current; + if (!enough(trimmed)) { + setFound(NONE); + return; + } + setFound((current) => ({ ...current, loading: true, error: null })); + const timer = setTimeout(() => { + api + .call("chat.search", { query: trimmed, limit: PAGE, ...(crew ? { crewId: crew } : {}) }) + .then( + (hits) => + ask === latest.current && + setFound({ hits, more: hits.length === PAGE, loading: false, error: null }), + (error) => ask === latest.current && setFound({ ...NONE, error: errorText(error) }), + ); + }, TYPING_MS); + return () => clearTimeout(timer); + }, [api, trimmed, crew]); + + const loadMore = () => { + const before = found.hits.at(-1)?.item.id; + if (!before) { + return; + } + const ask = latest.current; + setFound((current) => ({ ...current, loading: true })); + api + .call("chat.search", { + query: trimmed, + limit: PAGE, + before, + ...(crew ? { crewId: crew } : {}), + }) + .then( + (older) => + ask === latest.current && + setFound((current) => ({ + hits: [...current.hits, ...older], + more: older.length === PAGE, + loading: false, + error: null, + })), + (error) => + ask === latest.current && + setFound((current) => ({ ...current, loading: false, error: errorText(error) })), + ); + }; + + const scopes = [ + { value: "" as CrewId | "", label: words.allCrews }, + ...crews.map((c) => ({ value: c.id as CrewId | "", label: c.name })), + ]; + const searched = enough(trimmed); + + return ( +
      +
      + +
      + + setQuery(event.target.value)} + // What was searched before comes back selected, ready to replace. + onFocus={(event) => event.currentTarget.select()} + className="h-10 min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted" + /> + {found.loading && ( + + )} +
      +
      +