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