| 1 | import { Code2, MessagesSquare, Search, Sparkles } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { NavLink } from "react-router"; |
| 4 | |
| 5 | import type { ChatSidebarEntry } from "@g1t/contracts"; |
| 6 | |
| 7 | import { PaletteKey } from "./command-palette"; |
| 8 | import { StatusDot } from "./chat/marks"; |
| 9 | import { isOrchestrator } from "./orchestrator"; |
| 10 | import type { ShellAgent, ShellData } from "./shell"; |
| 11 | import { channelPath } from "../lib/chat"; |
| 12 | |
| 13 | /** Most of each kind under Recent. */ |
| 14 | const RECENT_CHATS = 5; |
| 15 | const RECENT_PROJECTS = 4; |
| 16 | const RECENT_AGENTS = 3; |
| 17 | |
| 18 | type Item = { key: string; to: string; label: string; mode: "chat" | "code" | "agents"; trailing?: ReactNode }; |
| 19 | |
| 20 | const MODE_ICON: Record<Item["mode"], ReactNode> = { |
| 21 | chat: <MessagesSquare size={15} />, |
| 22 | code: <Code2 size={15} />, |
| 23 | agents: <Sparkles size={15} />, |
| 24 | }; |
| 25 | const MODE_WORD: Record<Item["mode"], string> = { chat: "Chat", code: "Code", agents: "Agents" }; |
| 26 | |
| 27 | function Row({ item }: { item: Item }) { |
| 28 | return ( |
| 29 | <li> |
| 30 | <NavLink |
| 31 | to={item.to} |
| 32 | prefetch="intent" |
| 33 | className={({ isActive }) => |
| 34 | `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ |
| 35 | isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" |
| 36 | }` |
| 37 | } |
| 38 | > |
| 39 | <span className="shrink-0 text-faint group-hover:text-muted" aria-hidden="true"> |
| 40 | {MODE_ICON[item.mode]} |
| 41 | </span> |
| 42 | <span className="sr-only">{MODE_WORD[item.mode]}: </span> |
| 43 | <span className="min-w-0 grow truncate">{item.label}</span> |
| 44 | {item.trailing} |
| 45 | </NavLink> |
| 46 | </li> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | function Group({ title, items, empty }: { title: string; items: Item[]; empty: string }) { |
| 51 | return ( |
| 52 | <section className="mt-4"> |
| 53 | <h3 className="mb-1 px-2 text-xs font-medium text-faint">{title}</h3> |
| 54 | {items.length === 0 ? <p className="px-2 py-1 text-xs text-faint">{empty}</p> : <ul className="space-y-px">{items.map((item) => <Row key={item.key} item={item} />)}</ul>} |
| 55 | </section> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | function chatItem(slug: string, entry: ChatSidebarEntry): Item { |
| 60 | const count = entry.mentions || entry.unread; |
| 61 | return { |
| 62 | key: `chat:${entry.channel.id}`, |
| 63 | to: channelPath(slug, entry.channel), |
| 64 | label: entry.channel.kind === "channel" ? `#${entry.channel.name}` : entry.title, |
| 65 | mode: "chat", |
| 66 | trailing: |
| 67 | count > 0 && !entry.muted ? ( |
| 68 | <span |
| 69 | className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions ? "bg-accent text-bg" : "bg-line-strong text-fg"}`} |
| 70 | > |
| 71 | {count} |
| 72 | </span> |
| 73 | ) : null, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | function agentItem(slug: string, agent: ShellAgent): Item { |
| 78 | return { |
| 79 | key: `agent:${agent.id}`, |
| 80 | to: `/${slug}/-/agents/${agent.handle}`, |
| 81 | label: agent.display_name, |
| 82 | mode: "agents", |
| 83 | trailing: isOrchestrator(agent) ? null : <StatusDot status={agent.status} />, |
| 84 | }; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Home's sidebar: never Code's. A way to search or jump, what you pinned |
| 89 | * across modes (starred channels and conversations, pinned projects, and |
| 90 | * docs once they exist), and what you were in last, each marked with its |
| 91 | * mode. |
| 92 | */ |
| 93 | export function HomeSidebar({ |
| 94 | slug, |
| 95 | shell, |
| 96 | code, |
| 97 | onFind, |
| 98 | header, |
| 99 | }: { |
| 100 | slug: string; |
| 101 | shell: ShellData; |
| 102 | /** Whether the viewer uses Code here: without it, no projects. */ |
| 103 | code: boolean; |
| 104 | onFind: () => void; |
| 105 | header: ReactNode; |
| 106 | }) { |
| 107 | const chat = shell.chat; |
| 108 | const starredChats = chat?.starred ?? []; |
| 109 | const starred: Item[] = [ |
| 110 | ...starredChats.map((entry) => chatItem(slug, entry)), |
| 111 | ...(code ? (shell.pinned ?? []) : []).map((project) => ({ |
| 112 | key: `project:${project.namespace}/${project.name}`, |
| 113 | to: `/${project.namespace}/${project.name}`, |
| 114 | label: project.title ?? project.name, |
| 115 | mode: "code" as const, |
| 116 | })), |
| 117 | ]; |
| 118 | const starredIds = new Set(starredChats.map((entry) => entry.channel.id)); |
| 119 | const pinnedProjects = new Set((shell.pinned ?? []).map((project) => `${project.namespace}/${project.name}`)); |
| 120 | const agents = shell.agents ?? []; |
| 121 | const recent: Item[] = [ |
| 122 | ...(chat?.recent ?? []).filter((entry) => !starredIds.has(entry.channel.id)).slice(0, RECENT_CHATS).map((entry) => chatItem(slug, entry)), |
| 123 | ...(code ? (shell.recent ?? []) : []) |
| 124 | .filter((project) => !pinnedProjects.has(`${project.namespace}/${project.name}`)) |
| 125 | .slice(0, RECENT_PROJECTS) |
| 126 | .map((project) => ({ |
| 127 | key: `project:${project.namespace}/${project.name}`, |
| 128 | to: `/${project.namespace}/${project.name}`, |
| 129 | label: project.title ?? project.name, |
| 130 | mode: "code" as const, |
| 131 | })), |
| 132 | ...[...agents] |
| 133 | .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || Number(b.status !== "idle") - Number(a.status !== "idle")) |
| 134 | .slice(0, RECENT_AGENTS) |
| 135 | .map((agent) => agentItem(slug, agent)), |
| 136 | ]; |
| 137 | return ( |
| 138 | <div className="flex h-full flex-col"> |
| 139 | {header} |
| 140 | <div className="px-2.5 pt-3"> |
| 141 | <button |
| 142 | type="button" |
| 143 | onClick={onFind} |
| 144 | className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong" |
| 145 | > |
| 146 | <Search size={14} /> |
| 147 | <span className="grow text-left">Search or jump to…</span> |
| 148 | <PaletteKey className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line" /> |
| 149 | </button> |
| 150 | </div> |
| 151 | <nav aria-label="Home" className="min-h-0 grow overflow-y-auto px-2.5 pb-4 [scrollbar-width:thin]"> |
| 152 | <Group title="Starred" items={starred} empty="Star a channel or pin a project to keep it here." /> |
| 153 | <Group title="Recent" items={recent} empty={chat == null ? "Recent conversations show here once chat answers." : "Nothing yet."} /> |
| 154 | </nav> |
| 155 | </div> |
| 156 | ); |
| 157 | } |