Add hooks, @file mentions, session search/fork/export, follow-scroll

- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks
- @file mentions with autocomplete; mentioned files/folders are attached to the prompt
- Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON
- Chat follows the newest message until the user scrolls up; jump-to-latest button
- SVG activity spark and thinking icon instead of Unicode/emoji glyphs
- File search ranks name matches first and returns relative paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 20:17:12 +01:00
1 parent ec1b9b4925
commit 013695722a
17 files changed
+911 -41

No files matched your search

+51 -4
View File
@@ -1,6 +1,6 @@
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useMemo, useSyncExternalStore } from "react";
import { post } from "../api";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, post } from "../api";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
@@ -27,6 +27,26 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
// Full-text search over titles and messages.
const [query, setQuery] = useState("");
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setHits(undefined);
return;
}
let alive = true;
const timer = setTimeout(() => {
get<typeof hits>(`/sessions/search?q=${encodeURIComponent(q)}`)
.then((h) => alive && setHits(h ?? []))
.catch(() => alive && setHits([]));
}, 250);
return () => {
alive = false;
clearTimeout(timer);
};
}, [query]);
const attention = useMemo(() => {
const ids = new Set<string>();
@@ -60,9 +80,36 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
+ {t("sidebar.newSession")}
</button>
</div>
<div className="session-search">
<input
type="search"
value={query}
placeholder={t("sidebar.search")}
aria-label={t("sidebar.search")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
/>
</div>
<nav className="session-list">
{sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
{groups.map((g) => (
{hits && (
<>
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
{hits.map((h) => (
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
<div className="title">
<span>{h.title || t("session.untitled")}</span>
</div>
{h.snippet && <div className="sub snippet">{h.snippet}</div>}
<div className="sub">
{basename(h.cwd)} · {h.agent} · {fmtRelative(h.updated_at)}
</div>
</button>
))}
</>
)}
{!hits && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
{!hits && groups.map((g) => (
<div key={g.key}>
<div className="session-group">{t(g.key)}</div>
{g.items.map((s) => (