@@ -1,4 +1,4 @@ |
| 1 | | -import { useEffect } from "react"; |
| 1 | +import { useEffect, useState } from "react"; |
| 2 | 2 | import { |
| 3 | 3 | Panel, |
| 4 | 4 | PanelGroup, |
@@ -7,19 +7,22 @@ import { |
| 7 | 7 | |
| 8 | 8 | import { ProjectsPane } from "@/components/ProjectsPane"; |
| 9 | 9 | import { ViewerPane } from "@/components/ViewerPane"; |
| 10 | +import { relativeTime, tildeify } from "@/lib/format"; |
| 10 | 11 | import { useSessionStore } from "@/lib/store/sessions"; |
| 11 | 12 | |
| 12 | 13 | export default function App() { |
| 13 | 14 | const loadProjects = useSessionStore((s) => s.loadProjects); |
| 14 | 15 | const subscribe = useSessionStore((s) => s.subscribeToChanges); |
| 15 | 16 | const subscribeChat = useSessionStore((s) => s.subscribeToChatEvents); |
| 17 | + const subscribePty = useSessionStore((s) => s.subscribeToPtyEvents); |
| 16 | 18 | const error = useSessionStore((s) => s.error); |
| 17 | 19 | |
| 18 | 20 | useEffect(() => { |
| 19 | 21 | void loadProjects(); |
| 20 | 22 | void subscribe(); |
| 21 | 23 | void subscribeChat(); |
| 22 | | - }, [loadProjects, subscribe, subscribeChat]); |
| 24 | + void subscribePty(); |
| 25 | + }, [loadProjects, subscribe, subscribeChat, subscribePty]); |
| 23 | 26 | |
| 24 | 27 | return ( |
| 25 | 28 | <div className="flex h-screen flex-col bg-bg-0 text-fg-1"> |
@@ -57,7 +60,132 @@ function TitleBar() { |
| 57 | 60 | <div className="size-2 rounded-full bg-accent" /> |
| 58 | 61 | <span className="text-sm font-medium text-fg-0">claudex</span> |
| 59 | 62 | </div> |
| 60 | | - <div className="text-xs text-fg-3">thread browser</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 | + )} |
| 61 | 189 | </div> |
| 62 | 190 | ); |
| 63 | 191 | } |