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
@@ -19,16 +19,37 @@ export function Spinner({ small }: { small?: boolean }) {
|
||||
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
||||
/** Animated spark (SVG, looks the same on every system): rays light up in turn while it turns. */
|
||||
export function Star() {
|
||||
const rays = Array.from({ length: 8 }, (_, i) => i);
|
||||
return (
|
||||
<svg className="star" viewBox="-12 -12 24 24" aria-hidden="true">
|
||||
<g className="star-spin">
|
||||
{rays.map((i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1="0"
|
||||
y1="-4"
|
||||
x2="0"
|
||||
y2={i % 2 ? "-8.5" : "-10.5"}
|
||||
transform={`rotate(${i * 45})`}
|
||||
style={{ animationDelay: `${(i * 0.9) / 8 - 0.9}s` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rotating star like a terminal agent's spinner. */
|
||||
function Star() {
|
||||
const [i, setI] = useState(0);
|
||||
useEffect(() => {
|
||||
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
|
||||
return () => clearInterval(h);
|
||||
}, []);
|
||||
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
|
||||
/** Thought bubble icon for model reasoning (SVG instead of an emoji). */
|
||||
export function ThinkIcon() {
|
||||
return (
|
||||
<svg className="think-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 15.5a4.5 4.5 0 0 1-.6-8.96A5.5 5.5 0 0 1 16.6 5.2 4.25 4.25 0 0 1 18 13.5a4 4 0 0 1-4 2Z" />
|
||||
<circle cx="7.5" cy="19" r="1.6" />
|
||||
<circle cx="4" cy="21.6" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function useNow(active: boolean) {
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -58,6 +58,9 @@ export function Composer({
|
||||
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const [active, setActive] = useState(0);
|
||||
// @file mentions: token before the caret and matching files.
|
||||
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
||||
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||
|
||||
@@ -69,15 +72,57 @@ export function Composer({
|
||||
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
||||
}, [text]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const updateMention = (value: string, caret: number) => {
|
||||
const m = value.slice(0, caret).match(/(?:^|\s)@([\w./~+\-]*)$/);
|
||||
setMention(m ? { start: caret - m[1].length - 1, end: caret, q: m[1] } : undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mention || mention.q.length < 1) {
|
||||
setMentionHits([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const timer = setTimeout(() => {
|
||||
fetch(`/api/files/search?path=${encodeURIComponent(session.cwd)}&q=${encodeURIComponent(mention.q)}`, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((hits: { rel: string; is_dir: boolean }[]) => alive && setMentionHits(hits.slice(0, 12)))
|
||||
.catch(() => {});
|
||||
}, 120);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [mention?.q, session.cwd]);
|
||||
|
||||
const suggestions = useMemo((): { key: string; label: string; description: string; apply: () => void }[] => {
|
||||
if (mention && mentionHits.length) {
|
||||
return mentionHits.map((h) => ({
|
||||
key: h.rel,
|
||||
label: `@${h.rel}${h.is_dir ? "/" : ""}`,
|
||||
description: h.is_dir ? t("composer.folder") : "",
|
||||
apply: () => {
|
||||
const insert = `@${h.rel}${h.is_dir ? "/" : ""} `;
|
||||
const next = text.slice(0, mention.start) + insert + text.slice(mention.end);
|
||||
setText(next);
|
||||
setMention(undefined);
|
||||
setMentionHits([]);
|
||||
const caret = mention.start + insert.length;
|
||||
requestAnimationFrame(() => ref.current?.setSelectionRange(caret, caret));
|
||||
},
|
||||
}));
|
||||
}
|
||||
const m = text.match(/^\/(\S*)$/);
|
||||
if (!m) return [];
|
||||
const all: Suggestion[] = [
|
||||
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||
...(cmdData?.commands ?? []),
|
||||
];
|
||||
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
|
||||
}, [text, cmdData, t]);
|
||||
return all
|
||||
.filter((c) => c.name.startsWith(m[1]))
|
||||
.slice(0, 12)
|
||||
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
|
||||
}, [text, cmdData, t, mention, mentionHits]);
|
||||
|
||||
const send = () => {
|
||||
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||
@@ -97,7 +142,13 @@ export function Composer({
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
|
||||
suggestions[Math.min(active, suggestions.length - 1)].apply();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMention(undefined);
|
||||
setMentionHits([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -158,17 +209,17 @@ export function Composer({
|
||||
<div className="suggest" role="listbox">
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.name}
|
||||
key={s.key}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? "active" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setText(`/${s.name} `);
|
||||
s.apply();
|
||||
ref.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className="name">/{s.name}</span>
|
||||
<span className="name">{s.label}</span>
|
||||
<span className="desc">{s.description}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -208,7 +259,9 @@ export function Composer({
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setActive(0);
|
||||
updateMention(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onClick={(e) => updateMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
+11
-1
@@ -123,6 +123,7 @@ export const de: Messages = {
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "Noch keine Commits.",
|
||||
"git.noDiff": "Keine Unterschiede.",
|
||||
"composer.folder": "Ordner",
|
||||
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
||||
"composer.uploading": "Lade {n} hoch…",
|
||||
"usage.title": "Modelle und Verbrauch dieser Session",
|
||||
@@ -234,6 +235,15 @@ export const de: Messages = {
|
||||
"settings.reasoningHidden": "Aus",
|
||||
"settings.reasoningHint": "Die Überlegungen des Modells (sofern Modell und Provider sie liefern).",
|
||||
"settings.reasoningLive": "Beim Denken offen, danach einklappen",
|
||||
"chat.jumpLatest": "Zur neuesten Nachricht",
|
||||
"sidebar.search": "Chats durchsuchen…",
|
||||
"sidebar.searchResults": "Treffer ({n})",
|
||||
"sidebar.noResults": "Nichts gefunden",
|
||||
"chat.fork": "Forken: in einer Kopie dieses Chats weitermachen",
|
||||
"chat.forked": "Geforkt – du bist jetzt in der Kopie",
|
||||
"chat.export": "Exportieren",
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||
"chat.context": "Kontext",
|
||||
@@ -244,7 +254,7 @@ export const de: Messages = {
|
||||
"chat.delete": "Session löschen",
|
||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, Dateien anhängen oder einfügen)",
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, @ für Dateien, Dateien anhängen oder einfügen)",
|
||||
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||
"composer.send": "Senden",
|
||||
"composer.queue": "Einreihen",
|
||||
|
||||
+11
-1
@@ -122,6 +122,7 @@ export const en = {
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "No commits yet.",
|
||||
"git.noDiff": "No differences.",
|
||||
"composer.folder": "folder",
|
||||
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
||||
"composer.uploading": "Uploading {n}…",
|
||||
"usage.title": "Models and usage in this session",
|
||||
@@ -233,6 +234,15 @@ export const en = {
|
||||
"settings.reasoningHidden": "Off",
|
||||
"settings.reasoningHint": "Thinking of the model (if the model and provider return it).",
|
||||
"settings.reasoningLive": "Open while thinking, then collapse",
|
||||
"chat.jumpLatest": "Jump to the latest message",
|
||||
"sidebar.search": "Search chats…",
|
||||
"sidebar.searchResults": "Results ({n})",
|
||||
"sidebar.noResults": "Nothing found",
|
||||
"chat.fork": "Fork: continue in a copy of this chat",
|
||||
"chat.forked": "Forked – you are now in the copy",
|
||||
"chat.export": "Export",
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.compacted": "Conversation compacted – summary",
|
||||
"chat.context": "Context",
|
||||
@@ -243,7 +253,7 @@ export const en = {
|
||||
"chat.delete": "Delete session",
|
||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||
|
||||
"composer.placeholder": "Message… (/ for commands, attach or paste files)",
|
||||
"composer.placeholder": "Message… (/ for commands, @ for files, attach or paste files)",
|
||||
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||
"composer.send": "Send",
|
||||
"composer.queue": "Queue",
|
||||
|
||||
+118
-5
@@ -1362,6 +1362,8 @@ a.button:hover {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dots i,
|
||||
.spinner,
|
||||
.star line,
|
||||
.star-spin,
|
||||
.busy-ring,
|
||||
.tool.running,
|
||||
.streaming .markdown > :last-child::after,
|
||||
@@ -1964,11 +1966,43 @@ table.grid.sheet th {
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--accent2);
|
||||
font-size: 15px;
|
||||
width: 1em;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: -3px;
|
||||
overflow: visible;
|
||||
}
|
||||
.star line {
|
||||
stroke: var(--accent2);
|
||||
stroke-width: 2.6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.25;
|
||||
animation: star-ray 0.9s linear infinite;
|
||||
}
|
||||
.star-spin {
|
||||
transform-origin: 0 0;
|
||||
animation: spin 6s linear infinite;
|
||||
}
|
||||
@keyframes star-ray {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
.think-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: -2px;
|
||||
fill: none;
|
||||
stroke: var(--fg3);
|
||||
stroke-width: 1.6;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.think-icon circle {
|
||||
fill: var(--fg3);
|
||||
stroke: none;
|
||||
}
|
||||
.activity-text {
|
||||
color: var(--accent2);
|
||||
@@ -2204,3 +2238,82 @@ button.danger-solid {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* --- jump to latest message ---------------------------------------------------- */
|
||||
.jump-latest {
|
||||
position: sticky;
|
||||
bottom: 14px;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
.jump-latest button {
|
||||
pointer-events: auto;
|
||||
transform: translateY(-100%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* --- session search -------------------------------------------------------------- */
|
||||
.session-search {
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
.session-search input {
|
||||
width: 100%;
|
||||
}
|
||||
.session-item .snippet {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* --- small popup menu (export) ---------------------------------------------- */
|
||||
.menu-pop {
|
||||
position: relative;
|
||||
}
|
||||
.menu-pop > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.menu-pop > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.menu-pop > summary:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.menu-pop-body {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 20;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
}
|
||||
.menu-pop-body a {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--fg1);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-pop-body a:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
Reference in new issue
Block a user