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
@@ -4,9 +4,9 @@ import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, useStore } from "../store";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||
import { ActivityLine, Dots, WorkBar } from "./Activity";
|
||||
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
@@ -45,7 +45,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
|
||||
return (
|
||||
<div className={`reasoning ${live ? "live" : ""}`}>
|
||||
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
||||
{live ? <Dots /> : <span aria-hidden="true">💭</span>}
|
||||
{live ? <Dots /> : <ThinkIcon />}
|
||||
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
@@ -128,7 +128,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Follow the newest message until the user scrolls up; the ↓ button resumes.
|
||||
const stick = useRef(true);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const lastScroll = useRef({ top: 0, height: 0 });
|
||||
const follow = (on: boolean) => {
|
||||
stick.current = on;
|
||||
setFollowing(on);
|
||||
};
|
||||
const jumpToLatest = () => {
|
||||
follow(true);
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Every chat starts following.
|
||||
useEffect(() => follow(true), [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||
@@ -165,6 +180,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
}
|
||||
};
|
||||
|
||||
const forkSession = async () => {
|
||||
try {
|
||||
const fork = await post<Session>(`/sessions/${sessionId}/fork`, {});
|
||||
await loadSessions();
|
||||
toast(t("chat.forked"));
|
||||
navigate(`/s/${fork.id}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async (raw: string, images: ImageData[]) => {
|
||||
const txt = raw.trim();
|
||||
if (txt === "/new") {
|
||||
@@ -174,7 +200,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
try {
|
||||
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
|
||||
setText("");
|
||||
stick.current = true;
|
||||
follow(true);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
@@ -262,6 +288,24 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
⇄
|
||||
</button>
|
||||
)}
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
🔀
|
||||
</button>
|
||||
)}
|
||||
<details className="menu-pop">
|
||||
<summary className="small ghost" title={t("chat.export")}>
|
||||
💾
|
||||
</summary>
|
||||
<div className="menu-pop-body">
|
||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||
{t("chat.exportMd")}
|
||||
</a>
|
||||
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
|
||||
{t("chat.exportJson")}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
⇊
|
||||
</button>
|
||||
@@ -277,7 +321,16 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
const last = lastScroll.current;
|
||||
const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
// Scrolling up (not content shrinking) stops following …
|
||||
if (el.scrollTop < last.top - 2 && el.scrollHeight >= last.height && stick.current) follow(false);
|
||||
// … scrolling back to the very end resumes it.
|
||||
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
||||
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
if (e.deltaY < 0 && stick.current) follow(false);
|
||||
}}
|
||||
>
|
||||
<div className="chat">
|
||||
@@ -382,6 +435,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!following && (
|
||||
<div className="jump-latest">
|
||||
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
|
||||
Reference in new issue
Block a user