new look
This commit is contained in:
201
web/src/App.tsx
201
web/src/App.tsx
@@ -248,7 +248,7 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<div className="flex h-9 min-w-56 items-center rounded-md border border-input bg-background px-2 text-sm">
|
||||
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
@@ -289,12 +289,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
|
||||
<div className="absolute right-0 z-50 mt-2 w-full rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{normalizedDraftValue && !hasExactOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||
onClick={commitDraftValue}
|
||||
>
|
||||
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
|
||||
@@ -306,7 +306,7 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
@@ -378,6 +378,32 @@ function formatDate(value: string) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getSidebarSectionLabel(value: string) {
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfItemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const dayDelta = Math.floor((startOfToday - startOfItemDay) / dayMs);
|
||||
|
||||
if (dayDelta <= 0) return "TODAY";
|
||||
if (dayDelta < 7) return "LAST 7 DAYS";
|
||||
return "EARLIER";
|
||||
}
|
||||
|
||||
function buildSidebarSections(items: SidebarItem[]) {
|
||||
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
const label = getSidebarSectionLabel(item.updatedAt);
|
||||
const section = sections.find((candidate) => candidate.label === label);
|
||||
if (section) {
|
||||
section.items.push(item);
|
||||
} else {
|
||||
sections.push({ label, items: [item] });
|
||||
}
|
||||
return sections;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
authTokenInput,
|
||||
@@ -422,6 +448,7 @@ export default function App() {
|
||||
const wasSendingRef = useRef(false);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
||||
const hasSyncedSelectionHistoryRef = useRef(false);
|
||||
|
||||
@@ -442,6 +469,17 @@ export default function App() {
|
||||
}, [composer]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
if (!query) return sidebarItems;
|
||||
return sidebarItems.filter((item) => {
|
||||
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
||||
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
});
|
||||
}, [sidebarItems, sidebarQuery]);
|
||||
const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]);
|
||||
|
||||
const resetWorkspaceState = () => {
|
||||
setChats([]);
|
||||
@@ -1171,12 +1209,12 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex h-full w-full overflow-hidden bg-background">
|
||||
<div className="app-grid-surface h-full p-0 md:p-2">
|
||||
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||
{isMobileSidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-30 bg-black/45 md:hidden"
|
||||
className="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm md:hidden"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
@@ -1184,22 +1222,37 @@ export default function App() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||
"glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
||||
<div className="px-4 pb-4 pt-5">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
</Button>
|
||||
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
New search
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||
<input
|
||||
value={sidebarQuery}
|
||||
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
||||
placeholder="Search chats"
|
||||
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<Separator className="bg-violet-300/10" />
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||
@@ -1207,45 +1260,60 @@ export default function App() {
|
||||
Start a chat or run your first search.
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarItems.map((item) => {
|
||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
||||
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
className={cn(
|
||||
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
||||
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
|
||||
)}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setSelectedItem({ kind: item.kind, id: item.id });
|
||||
setIsMobileSidebarOpen(false);
|
||||
}}
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs">
|
||||
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
|
||||
{initiatedLabel ? (
|
||||
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
|
||||
) : null}
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.label} className="mb-4">
|
||||
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
||||
{section.items.map((item) => {
|
||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
||||
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
className={cn(
|
||||
"mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
|
||||
active
|
||||
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
|
||||
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setDraftKind(null);
|
||||
setSelectedItem({ kind: item.kind, id: item.id });
|
||||
setIsMobileSidebarOpen(false);
|
||||
}}
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
||||
)}
|
||||
>
|
||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<p className="truncate text-sm font-semibold">{item.title}</p>
|
||||
<p className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</p>
|
||||
</div>
|
||||
{initiatedLabel ? (
|
||||
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
|
||||
<div className="flex items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1259,18 +1327,19 @@ export default function App() {
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
{isSearchMode ? " • Exa Search" : ""}
|
||||
</p>
|
||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
{isSearchMode ? " • Exa Search" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||
{!isSearchMode ? (
|
||||
<>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={provider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
@@ -1299,7 +1368,7 @@ export default function App() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
|
||||
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
||||
<Globe2 className="mr-2 h-4 w-4" />
|
||||
Search mode
|
||||
</div>
|
||||
@@ -1309,7 +1378,7 @@ export default function App() {
|
||||
|
||||
<div
|
||||
ref={transcriptContainerRef}
|
||||
className={cn("flex-1 overflow-y-auto px-3 pt-6 md:px-10", isSearchMode ? "pb-6" : "pb-28 md:pb-40")}
|
||||
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44"
|
||||
onScroll={() => {
|
||||
const container = transcriptContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -1325,8 +1394,8 @@ export default function App() {
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t p-3 md:p-4">
|
||||
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
|
||||
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
|
||||
<div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
|
||||
<Textarea
|
||||
id="composer-input"
|
||||
rows={1}
|
||||
@@ -1343,13 +1412,13 @@ export default function App() {
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
|
||||
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
||||
<Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1360,13 +1429,13 @@ export default function App() {
|
||||
{contextMenu ? (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
|
||||
className="fixed z-50 min-w-40 rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||
onClick={() => void handleDeleteFromContextMenu()}
|
||||
disabled={isSending}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user