Skip to content

Commit f93ddc6

Browse files
authored
Merge pull request #134 from httpsphl/feat/chat-search
feat: search the chats
2 parents 651ffd9 + b858d45 commit f93ddc6

40 files changed

Lines changed: 1528 additions & 126 deletions

‎app/src/features/chat/ChatView.tsx‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { useT } from "../../i18n";
1616
import { day } from "../../lib/format";
1717
import type { Bot } from "../../lib/protocol.gen";
18+
import { useApp } from "../../store/context";
1819
import { Button } from "../../ui/Button";
1920
import { Callout } from "../../ui/Callout";
2021
import { SeenSince } from "../../ui/motion";
@@ -23,11 +24,14 @@ import { BotRun, type Live } from "./BotRun";
2324
import { ChatComposer } from "./ChatComposer";
2425
import { InboundRow } from "./InboundRow";
2526
import { NoticeRow } from "./NoticeRow";
26-
import { chatRows } from "./rows";
27+
import { chatRows, rowHas } from "./rows";
2728
import { useChat } from "./useChat";
2829
import { useFiles } from "./useFiles";
2930
import { useSharedSound } from "./useSharedSound";
3031

32+
/** How long a search result stays marked (`search.css`). */
33+
const FOCUS_MS = 2400;
34+
3135
/** Distance from the bottom that still counts as "reading the latest". */
3236
const STICKY_PX = 80;
3337

@@ -36,9 +40,13 @@ const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files"
3640
/** Skips the renders of the view around it; the text being written re-renders only the last run. */
3741
export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
3842
const t = useT();
39-
const chat = useChat(bot.id);
43+
const focus = useApp((state) => (state.focus?.botId === bot.id ? state.focus.itemId : null));
44+
const chat = useChat(bot.id, focus);
4045
const files = useFiles();
4146
const scroller = useRef<HTMLDivElement>(null);
47+
const listRef = useRef<HTMLOListElement>(null);
48+
/** The search result already shown, so it is not scrolled to again. */
49+
const shownFocus = useRef<string | null>(null);
4250
const atEnd = useRef(true);
4351
/** Distance from the bottom to keep while older items load above. */
4452
const anchor = useRef<number | null>(null);
@@ -111,6 +119,23 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
111119
/>
112120
);
113121
});
122+
// A search result opened the chat: bring its row into view, marked.
123+
useLayoutEffect(() => {
124+
if (!focus || shownFocus.current === focus) {
125+
return;
126+
}
127+
const row = listRef.current?.children[rows.findIndex((each) => rowHas(each, focus))];
128+
if (!row) {
129+
return;
130+
}
131+
shownFocus.current = focus;
132+
atEnd.current = false;
133+
row.scrollIntoView?.({ block: "center" });
134+
row.classList.add("search-focus");
135+
// As long as the band's animation, also when it does not move.
136+
setTimeout(() => row.classList.remove("search-focus"), FOCUS_MS);
137+
}, [focus, rows]);
138+
114139
if (live && !openRun) {
115140
list.push(<BotRun key="live" items={[]} bot={bot} live={live} />);
116141
}
@@ -177,7 +202,11 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
177202
</div>
178203
)}
179204
<SeenSince.Provider value={openedAt}>
180-
<ol aria-label={t.chat.view.messages} className="offscreen-rows flex flex-col gap-6">
205+
<ol
206+
ref={listRef}
207+
aria-label={t.chat.view.messages}
208+
className="offscreen-rows flex flex-col gap-6"
209+
>
181210
{list}
182211
</ol>
183212
</SeenSince.Provider>

‎app/src/features/chat/rows.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export function chatRows(items: ChatItem[]): Row[] {
6060
return rows;
6161
}
6262

63+
/** Whether the row shows the chat item `id`. */
64+
export function rowHas(row: Row, id: string): boolean {
65+
switch (row.kind) {
66+
case "day":
67+
return false;
68+
case "run":
69+
return row.items.some((item) => item.id === id);
70+
default:
71+
return row.item.id === id;
72+
}
73+
}
74+
6375
/** Tool calls that run together as lines; shared files are cards of their own. */
6476
function isLine(item: ChatItem | undefined): boolean {
6577
return item?.body.kind === "tool" && !isSharedFiles(item);

‎app/src/features/chat/useChat.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { useCallback, useEffect, useState } from "react";
66
import { errorText } from "../../lib/api";
7-
import type { BotId, ChatItem } from "../../lib/protocol.gen";
7+
import type { BotId, ChatItem, ChatItemId } from "../../lib/protocol.gen";
88
import { useApi, useApp } from "../../store/context";
99

1010
const PAGE = 50;
@@ -44,7 +44,8 @@ function merge(history: ChatItem[], live: ChatItem[]): ChatItem[] {
4444
return items;
4545
}
4646

47-
export function useChat(botId: BotId) {
47+
/** With `focus`, the chat loads from that item to the newest (spec 8.8). */
48+
export function useChat(botId: BotId, focus: ChatItemId | null = null) {
4849
const api = useApi();
4950
const open = useApp((state) => state.connection.kind === "open");
5051
const [page, setPage] = useState<ChatPage>(EMPTY);
@@ -71,13 +72,19 @@ export function useChat(botId: BotId) {
7172
setPage((current) => ({ ...current, draft: current.draft + text }));
7273
}
7374
});
74-
api.call("chat.history", { botId, limit: PAGE }).then(
75+
const newest = () => api.call("chat.history", { botId, limit: PAGE });
76+
// An item that is gone opens the chat as usual.
77+
const first = focus
78+
? api.call("chat.history", { botId, until: focus }).catch(newest)
79+
: newest();
80+
first.then(
7581
(history) => {
7682
if (alive) {
7783
setPage((current) => ({
7884
...current,
7985
items: merge(history, current.items),
80-
complete: history.length < PAGE,
86+
// From an item, older ones may still be there.
87+
complete: !focus && history.length < PAGE,
8188
loading: false,
8289
}));
8390
}
@@ -92,7 +99,7 @@ export function useChat(botId: BotId) {
9299
alive = false;
93100
unsubscribe();
94101
};
95-
}, [api, open, botId]);
102+
}, [api, open, botId, focus]);
96103

97104
const oldest = page.items[0]?.id;
98105
const loadOlder = useCallback(async () => {

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
ChevronDown,
3-
ChevronsDownUp,
4-
ChevronsUpDown,
5-
MessageCircleQuestion,
6-
Pause,
7-
Plus,
8-
} from "lucide-react";
1+
import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react";
92
import { memo, useCallback, useState } from "react";
103
import { createPortal } from "react-dom";
114
import { useShallow } from "zustand/react/shallow";
@@ -14,7 +7,6 @@ import { when } from "../../lib/format";
147
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
158
import { activityOf, botsOf, crewList } from "../../store/app";
169
import { useApp } from "../../store/context";
17-
import { openQuestions } from "../../store/questions";
1810
import { isUnread, unreadIn } from "../../store/seen";
1911
import { Button } from "../../ui/Button";
2012
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
@@ -27,13 +19,14 @@ import { ChiefBadge, isChief } from "../bots/ChiefBadge";
2719
import { toolAction, toolTitle } from "../chat/toolNames";
2820
import { CrewDialog } from "./CrewDialog";
2921
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
22+
import { QuestionsEntry, row, SearchEntry } from "./SidebarPages";
3023

3124
/** Crews as sections and their bots as conversations (spec 15.1). */
3225
export function Sidebar() {
3326
const t = useT();
3427
const words = t.crews.sidebar;
3528
const crews = useApp(useShallow(crewList));
36-
const overview = useApp((state) => state.selectedCrewId === null && !state.questionBox);
29+
const overview = useApp((state) => state.selectedCrewId === null && !state.page);
3730
const selectCrew = useApp((state) => state.selectCrew);
3831
const collapsed = useCollapsedSet();
3932
const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id));
@@ -68,6 +61,7 @@ export function Sidebar() {
6861
onClick={() => setCreating(true)}
6962
/>
7063
</div>
64+
<SearchEntry />
7165
<QuestionsEntry />
7266
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
7367
{crews.map((crew) => (
@@ -81,42 +75,6 @@ export function Sidebar() {
8175
);
8276
}
8377

84-
const row =
85-
"relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken";
86-
87-
/** The question box (spec 23.6), with how many questions wait. */
88-
function QuestionsEntry() {
89-
const t = useT();
90-
const words = t.questions.box;
91-
const count = useApp((state) => openQuestions(state).length);
92-
const open = useApp((state) => state.questionBox);
93-
const openBox = useApp((state) => state.openQuestionBox);
94-
return (
95-
<div className="shrink-0 px-2">
96-
<button
97-
type="button"
98-
aria-current={open ? "page" : undefined}
99-
title={words.open(count)}
100-
onClick={openBox}
101-
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
102-
>
103-
<MessageCircleQuestion
104-
aria-hidden
105-
size={16}
106-
className={count > 0 ? "text-warn" : "text-muted"}
107-
/>
108-
<span className="min-w-0 flex-1 truncate font-medium">{words.label}</span>
109-
{count > 0 && (
110-
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-warn px-1 font-semibold text-[11px] text-canvas tabular-nums">
111-
<span aria-hidden>{count}</span>
112-
<span className="sr-only">{words.open(count)}</span>
113-
</span>
114-
)}
115-
</button>
116-
</div>
117-
);
118-
}
119-
12078
function CrewEntry({ crew }: { crew: Crew }) {
12179
const t = useT();
12280
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// The pages at the top of the sidebar: the search (spec 8.8) and the
2+
// question box (spec 23.6).
3+
4+
import { MessageCircleQuestion, Search } from "lucide-react";
5+
import { useT } from "../../i18n";
6+
import { useApp } from "../../store/context";
7+
import { openQuestions } from "../../store/questions";
8+
9+
/** A row of the sidebar, as the conversations are. */
10+
export const row =
11+
"relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken";
12+
13+
/** The search (spec 8.8). */
14+
export function SearchEntry() {
15+
const t = useT();
16+
const open = useApp((state) => state.page === "search");
17+
const openPage = useApp((state) => state.openPage);
18+
return (
19+
<div className="shrink-0 px-2">
20+
<button
21+
type="button"
22+
aria-current={open ? "page" : undefined}
23+
title={t.search.open}
24+
onClick={() => openPage("search")}
25+
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
26+
>
27+
<Search aria-hidden size={16} className="text-muted" />
28+
<span className="min-w-0 flex-1 truncate font-medium">{t.search.label}</span>
29+
<kbd className="font-sans text-[11px] text-muted">Ctrl K</kbd>
30+
</button>
31+
</div>
32+
);
33+
}
34+
35+
/** The question box (spec 23.6), with how many questions wait. */
36+
export function QuestionsEntry() {
37+
const t = useT();
38+
const words = t.questions.box;
39+
const count = useApp((state) => openQuestions(state).length);
40+
const open = useApp((state) => state.page === "questions");
41+
const openPage = useApp((state) => state.openPage);
42+
return (
43+
<div className="shrink-0 px-2">
44+
<button
45+
type="button"
46+
aria-current={open ? "page" : undefined}
47+
title={words.open(count)}
48+
onClick={() => openPage("questions")}
49+
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
50+
>
51+
<MessageCircleQuestion
52+
aria-hidden
53+
size={16}
54+
className={count > 0 ? "text-warn" : "text-muted"}
55+
/>
56+
<span className="min-w-0 flex-1 truncate font-medium">{words.label}</span>
57+
{count > 0 && (
58+
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-warn px-1 font-semibold text-[11px] text-canvas tabular-nums">
59+
<span aria-hidden>{count}</span>
60+
<span className="sr-only">{words.open(count)}</span>
61+
</span>
62+
)}
63+
</button>
64+
</div>
65+
);
66+
}

0 commit comments

Comments
 (0)