From 47e6da63e92917a77896c7d9f1b3124c14d7e6fb Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sun, 26 Apr 2026 22:05:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=EF=BC=8C=E5=8A=A0=E5=85=A5=E6=B7=B1=E5=BA=A6=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/App.tsx | 309 ++++++++++++++++++++--- src/components/app/chat-view.tsx | 17 +- src/components/app/sidebar.tsx | 413 ++++++++++++++++++++++++------- src/index.css | 267 +++++++++++--------- src/types.ts | 23 ++ 5 files changed, 784 insertions(+), 245 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2e813b3..27d8eb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { FileText, Info, MessageSquare, + PanelLeft, Settings, SlidersHorizontal, } from "lucide-react" @@ -54,6 +55,8 @@ import type { ChatMessage, ChatSession, ChatToolCall, + DeepSearchResult, + DeepSearchState, ModelConfig, ModelParameters, PromptTemplate, @@ -106,10 +109,21 @@ const STATUS_TONE: Record = { done: "success", } +const DATA_SAVE_DELAY_MS = 800 +const DEEP_SEARCH_BATCH_SIZE = 32 +const DEEP_SEARCH_SNIPPET_LIMIT = 3 + function isBusy(status: RequestState) { return status === "requesting" || status === "generating" } +function isMobileSidebarViewport() { + return ( + typeof window !== "undefined" && + window.matchMedia("(max-width: 900px)").matches + ) +} + function getErrorMessage(error: unknown) { if (error instanceof Error) { return error.message @@ -118,6 +132,41 @@ function getErrorMessage(error: unknown) { return "发生未知错误" } +function getSearchableMessageContent(message: ChatMessage) { + return [message.content, message.reasoningContent] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n") +} + +function createDeepSearchExcerpt(content: string, query: string) { + const compactContent = content.replace(/\s+/g, " ").trim() + + if (!compactContent) { + return "空消息" + } + + const normalizedContent = compactContent.toLowerCase() + const normalizedQuery = query.toLowerCase() + const matchIndex = normalizedContent.indexOf(normalizedQuery) + + if (matchIndex < 0) { + return compactContent.length > 160 + ? `${compactContent.slice(0, 160)}...` + : compactContent + } + + const start = Math.max(0, matchIndex - 48) + const end = Math.min(compactContent.length, matchIndex + query.length + 96) + + return `${start > 0 ? "..." : ""}${compactContent.slice(start, end)}${ + end < compactContent.length ? "..." : "" + }` +} + +function yieldToBrowser() { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + function updateSessionInData( source: AppData, sessionId: string, @@ -249,8 +298,21 @@ function AppContent() { const initialLoad = React.useMemo(() => loadAppData(), []) const { setTheme } = useTheme() const [data, setData] = React.useState(initialLoad.data) + const latestDataRef = React.useRef(data) + const saveTimerRef = React.useRef(undefined) const [activeView, setActiveView] = React.useState("chat") + const [sidebarOpen, setSidebarOpen] = React.useState( + () => !isMobileSidebarViewport() + ) const [sessionSearch, setSessionSearch] = React.useState("") + const [deepSearch, setDeepSearch] = React.useState({ + open: false, + query: "", + status: "idle", + scanned: 0, + total: 0, + results: [], + }) const [chatInput, setChatInput] = React.useState("") const [editingMessageId, setEditingMessageId] = React.useState( null @@ -272,6 +334,7 @@ function AppContent() { const settingsImportRef = React.useRef(null) const allImportRef = React.useRef(null) const activeRequestRef = React.useRef(null) + const deepSearchRunRef = React.useRef(0) const currentSession = React.useMemo( () => @@ -328,14 +391,63 @@ function AppContent() { }, [data.prompts, promptSearch]) React.useEffect(() => { - const result = saveAppData(data) + latestDataRef.current = data + const hasBusySession = data.sessions.some((session) => + isBusy(session.status) + ) - if (!result.ok) { - const message = result.message - window.setTimeout(() => setStorageWarning(message), 0) + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = undefined + } + + if (hasBusySession) { + return undefined + } + + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = undefined + const result = saveAppData(latestDataRef.current) + + if (!result.ok) { + const message = result.message + window.setTimeout(() => setStorageWarning(message), 0) + } + }, DATA_SAVE_DELAY_MS) + + return () => { + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = undefined + } } }, [data]) + React.useEffect(() => { + const flushPendingSave = () => { + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = undefined + } + + saveAppData(latestDataRef.current) + } + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushPendingSave() + } + } + + window.addEventListener("pagehide", flushPendingSave) + document.addEventListener("visibilitychange", handleVisibilityChange) + + return () => { + window.removeEventListener("pagehide", flushPendingSave) + document.removeEventListener("visibilitychange", handleVisibilityChange) + } + }, []) + React.useEffect(() => { setTheme(data.settings.theme) }, [data.settings.theme, setTheme]) @@ -367,6 +479,119 @@ function AppContent() { })) } + function setDeepSearchOpen(open: boolean) { + if (!open) { + deepSearchRunRef.current += 1 + } + + setDeepSearch((previous) => ({ ...previous, open })) + } + + function updateDeepSearchQuery(query: string) { + setDeepSearch((previous) => ({ ...previous, query })) + } + + function openDeepSearch() { + void runDeepSearch(sessionSearch) + } + + async function runDeepSearch(rawQuery: string) { + const query = rawQuery.trim() + const runId = deepSearchRunRef.current + 1 + const sessions = [...latestDataRef.current.sessions].sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime() + ) + const total = sessions.reduce( + (count, session) => count + session.messages.length, + 0 + ) + + deepSearchRunRef.current = runId + setDeepSearch({ + open: true, + query: rawQuery, + status: query ? "searching" : "idle", + scanned: 0, + total, + results: [], + }) + + if (!query) { + return + } + + const normalizedQuery = query.toLowerCase() + const results: DeepSearchResult[] = [] + let scanned = 0 + let messagesSinceYield = 0 + + for (const session of sessions) { + let matchCount = 0 + const snippets: DeepSearchResult["snippets"] = [] + + for (const message of session.messages) { + const content = getSearchableMessageContent(message) + + if (content.toLowerCase().includes(normalizedQuery)) { + matchCount += 1 + + if (snippets.length < DEEP_SEARCH_SNIPPET_LIMIT) { + snippets.push({ + messageId: message.id, + role: message.role, + excerpt: createDeepSearchExcerpt(content, query), + }) + } + } + + scanned += 1 + messagesSinceYield += 1 + + if (messagesSinceYield >= DEEP_SEARCH_BATCH_SIZE) { + messagesSinceYield = 0 + setDeepSearch((previous) => + deepSearchRunRef.current === runId + ? { + ...previous, + scanned, + total, + results: [...results], + } + : previous + ) + await yieldToBrowser() + + if (deepSearchRunRef.current !== runId) { + return + } + } + } + + if (matchCount > 0) { + results.push({ + sessionId: session.id, + title: session.title, + updatedAt: session.updatedAt, + matchCount, + snippets, + }) + } + } + + if (deepSearchRunRef.current !== runId) { + return + } + + setDeepSearch((previous) => ({ + ...previous, + status: "done", + scanned: total, + total, + results, + })) + } + function updateService(serviceId: string, patch: Partial) { setData((previous) => ({ ...previous, @@ -431,6 +656,10 @@ function AppContent() { setChatInput("") setEditingMessageId(null) setActiveView("chat") + + if (isMobileSidebarViewport()) { + setSidebarOpen(false) + } } function renameSession(sessionId: string) { @@ -473,23 +702,14 @@ function AppContent() { }) } - function clearAllSessions() { - if (!window.confirm("确定清空所有会话吗?")) { - return - } - - const session = createSession() - setData((previous) => ({ - ...previous, - currentSessionId: session.id, - sessions: [session], - })) - } - function switchSession(sessionId: string) { setData((previous) => ({ ...previous, currentSessionId: sessionId })) setEditingMessageId(null) setActiveView("chat") + + if (isMobileSidebarViewport()) { + setSidebarOpen(false) + } } function validateAndReport(session: ChatSession) { @@ -1437,6 +1657,7 @@ function AppContent() {
@@ -1444,9 +1665,13 @@ function AppContent() { sessions={filteredSessions} currentSession={currentSession} search={sessionSearch} + deepSearch={deepSearch} onSearchChange={setSessionSearch} + onOpenDeepSearch={openDeepSearch} + onDeepSearchOpenChange={setDeepSearchOpen} + onDeepSearchQueryChange={updateDeepSearchQuery} + onRunDeepSearch={(query) => void runDeepSearch(query)} onNewSession={() => createNewSession()} - onClearSessions={clearAllSessions} onSwitchSession={switchSession} onRenameSession={renameSession} onDeleteSession={deleteSession} @@ -1454,23 +1679,37 @@ function AppContent() {
- +
+ + +
{configIsValid ? "配置有效" : "待配置"} diff --git a/src/components/app/chat-view.tsx b/src/components/app/chat-view.tsx index 30c1348..b693434 100644 --- a/src/components/app/chat-view.tsx +++ b/src/components/app/chat-view.tsx @@ -173,6 +173,18 @@ export function ChatView({ onOpenSettings, }: ChatViewProps) { const canSend = chatInput.trim().length > 0 && !isBusy + const messagesScrollRef = React.useRef(null) + + React.useEffect(() => { + const element = messagesScrollRef.current + + if (!element) { + return + } + + // Keep session switching predictable by jumping to the latest message. + element.scrollTop = element.scrollHeight + }, [currentSession.id]) return (
@@ -279,7 +291,10 @@ export function ChatView({
) : null} -
+
{currentSession.messages.length === 0 ? (
diff --git a/src/components/app/sidebar.tsx b/src/components/app/sidebar.tsx index a4b1041..1df8e45 100644 --- a/src/components/app/sidebar.tsx +++ b/src/components/app/sidebar.tsx @@ -1,9 +1,21 @@ -import { Bot, Edit3, Plus, Search, Trash2 } from "lucide-react" +import type * as React from "react" +import { Edit3, Plus, Search, Trash2, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" -import type { ChatSession, RequestState } from "@/types" +import type { + ChatSession, + DeepSearchState, + MessageRole, + RequestState, +} from "@/types" + +const ROLE_TEXT: Record = { + user: "你", + assistant: "助手", + tool: "工具", +} function formatDateTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { @@ -22,9 +34,13 @@ type AppSidebarProps = { sessions: ChatSession[] currentSession: ChatSession search: string + deepSearch: DeepSearchState onSearchChange: (value: string) => void + onOpenDeepSearch: () => void + onDeepSearchOpenChange: (open: boolean) => void + onDeepSearchQueryChange: (value: string) => void + onRunDeepSearch: (query: string) => void onNewSession: () => void - onClearSessions: () => void onSwitchSession: (sessionId: string) => void onRenameSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void @@ -34,107 +50,312 @@ export function AppSidebar({ sessions, currentSession, search, + deepSearch, onSearchChange, + onOpenDeepSearch, + onDeepSearchOpenChange, + onDeepSearchQueryChange, + onRunDeepSearch, onNewSession, - onClearSessions, onSwitchSession, onRenameSession, onDeleteSession, }: AppSidebarProps) { return ( - + + {deepSearch.open ? ( + + ) : null} + ) } + +type DeepSearchDialogProps = { + deepSearch: DeepSearchState + onOpenChange: (open: boolean) => void + onQueryChange: (value: string) => void + onRunSearch: (query: string) => void + onSwitchSession: (sessionId: string) => void +} + +function DeepSearchDialog({ + deepSearch, + onOpenChange, + onQueryChange, + onRunSearch, + onSwitchSession, +}: DeepSearchDialogProps) { + const progress = deepSearch.total + ? Math.round((deepSearch.scanned / deepSearch.total) * 100) + : deepSearch.status === "done" + ? 100 + : 0 + const isSearching = deepSearch.status === "searching" + + function openResult(sessionId: string) { + onSwitchSession(sessionId) + onOpenChange(false) + } + + return ( +
+
+
+
+

+ 深度搜索历史记录 +

+
+ {isSearching + ? `已扫描 ${deepSearch.scanned} / ${deepSearch.total} 条消息` + : `${deepSearch.results.length} 个会话匹配`} +
+
+ +
+ +
+
+
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault() + onRunSearch(deepSearch.query) + } + }} + autoFocus + placeholder="搜索历史记录" + className="pl-7" + /> +
+ +
+
+
+
+
+ +
+ {!deepSearch.query.trim() ? ( +
+ 输入关键词后开始搜索 +
+ ) : deepSearch.results.length === 0 ? ( +
+ {isSearching ? "正在扫描历史记录" : "没有找到匹配内容"} +
+ ) : ( +
+ {deepSearch.results.map((result) => ( + + ))} +
+ )} +
+
+
+ ) +} + +function HighlightedText({ text, query }: { text: string; query: string }) { + const needle = query.trim() + + if (!needle) { + return <>{text} + } + + const lowerText = text.toLowerCase() + const lowerNeedle = needle.toLowerCase() + const parts: React.ReactNode[] = [] + let cursor = 0 + let matchIndex = lowerText.indexOf(lowerNeedle) + + while (matchIndex >= 0) { + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)) + } + + const nextCursor = matchIndex + needle.length + parts.push( + + {text.slice(matchIndex, nextCursor)} + + ) + cursor = nextCursor + matchIndex = lowerText.indexOf(lowerNeedle, cursor) + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)) + } + + return <>{parts} +} diff --git a/src/index.css b/src/index.css index 48e00c7..10bbb8f 100644 --- a/src/index.css +++ b/src/index.css @@ -6,128 +6,128 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: 'Inter Variable', sans-serif; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: var(--font-sans); + --font-sans: "Inter Variable", sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; - } + } body { @apply bg-background text-foreground; font-size: calc(16px * var(--app-font-scale, 1)); - } + } html { @apply font-sans; - } + } } .app-shell { @@ -137,6 +137,14 @@ overflow: hidden; } +.app-shell-sidebar-collapsed { + grid-template-columns: minmax(0, 1fr); +} + +.app-shell-sidebar-collapsed .sidebar { + display: none; +} + .sidebar { display: flex; min-height: 0; @@ -156,6 +164,14 @@ gap: 0.75rem; } +.topbar-left { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 0.5rem; +} + .content-view { min-height: 0; flex: 1; @@ -189,7 +205,13 @@ border-radius: 0.875rem; background: #f3f3f3; color: #171717; - box-shadow:rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px + box-shadow: + rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 2px -1px; } .message-bubble-assistant { @@ -314,19 +336,34 @@ @media (max-width: 900px) { .app-shell { grid-template-columns: minmax(0, 1fr); - height: auto; + height: 100svh; min-height: 100svh; - overflow: visible; + overflow: hidden; } .sidebar { - max-height: 42svh; + position: fixed; + inset: 0; + z-index: 40; + max-height: none; border-right: 0; - border-bottom: 1px solid var(--border); + border-bottom: 0; + } + + .app-shell:not(.app-shell-sidebar-collapsed) .sidebar-header { + padding-left: 3.5rem; + } + + .app-shell:not(.app-shell-sidebar-collapsed) .sidebar-toggle-button { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 60; } .main-panel { - min-height: 58svh; + height: 100svh; + min-height: 0; } .topbar, @@ -335,6 +372,10 @@ flex-direction: column; } + .topbar-left { + width: 100%; + } + .chat-header-actions { width: 100%; } @@ -346,4 +387,4 @@ .message-bubble { max-width: 100%; } -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts index 935e251..ee159bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -118,6 +118,29 @@ export type ChatMessage = { toolCalls?: ChatToolCall[] } +export type DeepSearchSnippet = { + messageId: string + role: MessageRole + excerpt: string +} + +export type DeepSearchResult = { + sessionId: string + title: string + updatedAt: string + matchCount: number + snippets: DeepSearchSnippet[] +} + +export type DeepSearchState = { + open: boolean + query: string + status: "idle" | "searching" | "done" + scanned: number + total: number + results: DeepSearchResult[] +} + export type ServiceConfig = { id: string name: string