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:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user