Skip to content
157 linesCodeBlameRaw
1import { Code2, MessagesSquare, Search, Sparkles } from "lucide-react";
2import type { ReactNode } from "react";
3import { NavLink } from "react-router";
4
5import type { ChatSidebarEntry } from "@g1t/contracts";
6
7import { PaletteKey } from "./command-palette";
8import { StatusDot } from "./chat/marks";
9import { isOrchestrator } from "./orchestrator";
10import type { ShellAgent, ShellData } from "./shell";
11import { channelPath } from "../lib/chat";
12
13/** Most of each kind under Recent. */
14const RECENT_CHATS = 5;
15const RECENT_PROJECTS = 4;
16const RECENT_AGENTS = 3;
17
18type Item = { key: string; to: string; label: string; mode: "chat" | "code" | "agents"; trailing?: ReactNode };
19
20const MODE_ICON: Record<Item["mode"], ReactNode> = {
21 chat: <MessagesSquare size={15} />,
22 code: <Code2 size={15} />,
23 agents: <Sparkles size={15} />,
24};
25const MODE_WORD: Record<Item["mode"], string> = { chat: "Chat", code: "Code", agents: "Agents" };
26
27function 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
50function 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
59function 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
77function 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 */
93export 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}