TypeScript · 7313 bytes Raw Blame History
1 import { useEffect, useState } from "react";
2 import {
3 Panel,
4 PanelGroup,
5 PanelResizeHandle,
6 } from "react-resizable-panels";
7
8 import { ProjectsPane } from "@/components/ProjectsPane";
9 import { ViewerPane } from "@/components/ViewerPane";
10 import { relativeTime, tildeify } from "@/lib/format";
11 import { useSessionStore } from "@/lib/store/sessions";
12
13 export default function App() {
14 const loadProjects = useSessionStore((s) => s.loadProjects);
15 const subscribe = useSessionStore((s) => s.subscribeToChanges);
16 const subscribeChat = useSessionStore((s) => s.subscribeToChatEvents);
17 const subscribePty = useSessionStore((s) => s.subscribeToPtyEvents);
18 const error = useSessionStore((s) => s.error);
19
20 useEffect(() => {
21 void loadProjects();
22 void subscribe();
23 void subscribeChat();
24 void subscribePty();
25 }, [loadProjects, subscribe, subscribeChat, subscribePty]);
26
27 return (
28 <div className="flex h-screen flex-col bg-bg-0 text-fg-1">
29 <TitleBar />
30 {error && <ErrorBanner message={error} />}
31 <PanelGroup
32 direction="horizontal"
33 autoSaveId="claudex-layout-v2"
34 className="flex-1"
35 >
36 <Panel defaultSize={28} minSize={18} maxSize={50}>
37 <ProjectsPane />
38 </Panel>
39 <VerticalHandle />
40 <Panel defaultSize={72} minSize={30}>
41 <ViewerPane />
42 </Panel>
43 </PanelGroup>
44 </div>
45 );
46 }
47
48 function TitleBar() {
49 // macOS traffic lights sit at roughly (8, 8) → (72, 22). The label
50 // row is pinned to the bottom of the 56px bar via items-end + pb-2,
51 // which puts it ~20px below the lights vertically — so we only need
52 // the left padding to match the ProjectsPane header (pl-3) and the
53 // "claudex" label lines up over the threadlist column.
54 return (
55 <div
56 data-tauri-drag-region
57 className="flex h-14 shrink-0 items-end justify-between border-b border-border bg-bg-1 pb-2 pl-3 pr-4"
58 >
59 <div className="flex items-center gap-2">
60 <div className="size-2 rounded-full bg-accent" />
61 <span className="text-sm font-medium text-fg-0">claudex</span>
62 </div>
63 <div className="flex items-center gap-3">
64 <LivePtyIndicator />
65 <span className="text-xs text-fg-3">thread browser</span>
66 </div>
67 </div>
68 );
69 }
70
71 /** Titlebar pill that shows the live-PTY count and opens a popover
72 * listing each one with a close button. Hidden entirely when there
73 * are no live PTYs. */
74 function LivePtyIndicator() {
75 const ptyIds = useSessionStore((s) => s.ptyIds);
76 const ptyInfos = useSessionStore((s) => s.ptyInfos);
77 const projects = useSessionStore((s) => s.projects);
78 const selectSession = useSessionStore((s) => s.selectSession);
79 const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
80 const [open, setOpen] = useState(false);
81
82 const count = ptyIds.size;
83 if (count === 0) return null;
84
85 // Build a (sessionId, summary, info) list for every live PTY that
86 // is bound to a known session. Unbound PTYs are currently never
87 // created but we tolerate them by listing just the ptyId + cwd.
88 const rows: Array<{
89 sessionId: string;
90 ptyId: string;
91 title: string;
92 cwd: string;
93 projectName: string | null;
94 startedAt: string;
95 }> = [];
96 for (const [sessionId, ptyId] of ptyIds) {
97 const info = ptyInfos.get(ptyId);
98 let title = sessionId;
99 let projectName: string | null = null;
100 let cwd = info?.cwd ?? "";
101 for (const project of projects) {
102 const match = project.sessions.find((s) => s.id === sessionId);
103 if (match) {
104 title = match.title;
105 projectName = project.displayName;
106 if (!cwd && match.cwd) cwd = match.cwd;
107 break;
108 }
109 }
110 rows.push({
111 sessionId,
112 ptyId,
113 title,
114 cwd,
115 projectName,
116 startedAt: info?.startedAt ?? new Date().toISOString(),
117 });
118 }
119
120 return (
121 <div className="relative">
122 <button
123 type="button"
124 onClick={() => setOpen((x) => !x)}
125 className="flex items-center gap-1.5 rounded border border-green-900/60 bg-green-950/40 px-2 py-0.5 text-[11px] font-mono text-green-300 hover:bg-green-900/40"
126 title={`${count} live terminal${count === 1 ? "" : "s"}`}
127 >
128 <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-green-400" />
129 {count} terminal{count === 1 ? "" : "s"}
130 </button>
131 {open && (
132 <>
133 <div
134 className="fixed inset-0 z-40"
135 onClick={() => setOpen(false)}
136 aria-hidden
137 />
138 <div className="absolute right-0 top-full z-50 mt-1 max-h-[60vh] w-80 overflow-y-auto rounded border border-border bg-bg-1 shadow-lg">
139 <div className="border-b border-border px-3 py-2 text-[10px] uppercase tracking-wide text-fg-3">
140 live terminals
141 </div>
142 {rows.map((row) => {
143 const session = projects
144 .flatMap((p) => p.sessions)
145 .find((s) => s.id === row.sessionId);
146 return (
147 <div
148 key={row.ptyId}
149 className="flex items-start gap-2 border-b border-border/40 px-3 py-2 last:border-b-0 hover:bg-bg-2"
150 >
151 <button
152 type="button"
153 onClick={() => {
154 if (session) void selectSession(session);
155 setOpen(false);
156 }}
157 className="min-w-0 flex-1 text-left"
158 >
159 <div className="flex items-center gap-1.5">
160 <span className="inline-block h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-green-500" />
161 <span
162 className="truncate text-[12px] text-fg-1"
163 title={row.title}
164 >
165 {row.title}
166 </span>
167 </div>
168 <div className="truncate text-[10px] text-fg-3">
169 {row.projectName ?? tildeify(row.cwd)}
170 </div>
171 <div className="text-[9px] text-fg-3">
172 started {relativeTime(row.startedAt)}
173 </div>
174 </button>
175 <button
176 type="button"
177 onClick={() => void closeSessionPty(row.sessionId)}
178 title="close terminal"
179 className="shrink-0 rounded border border-red-900/60 bg-red-950/40 px-1.5 py-0.5 text-[9px] font-mono text-red-300 hover:bg-red-900/50"
180 >
181
182 </button>
183 </div>
184 );
185 })}
186 </div>
187 </>
188 )}
189 </div>
190 );
191 }
192
193 function ErrorBanner({ message }: { message: string }) {
194 return (
195 <div className="shrink-0 border-b border-red-900/60 bg-red-950/40 px-4 py-2 text-xs text-red-300">
196 {message}
197 </div>
198 );
199 }
200
201 function VerticalHandle() {
202 return (
203 <PanelResizeHandle className="group relative w-px bg-border transition hover:bg-accent">
204 <div className="absolute inset-y-0 -left-1 -right-1 z-10 group-hover:bg-accent/20" />
205 </PanelResizeHandle>
206 );
207 }