| 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 | } |