tenseleyflow/claudex / d4c1f7d

Browse files

ui: titlebar live-terminals pill + popover

Authored by espadonne
SHA
d4c1f7d38726a18fd1c6a465a42ecb7a170dd2de
Parents
4566d46
Tree
ac35731

1 changed file

StatusFile+-
M src/App.tsx 131 3
src/App.tsxmodified
@@ -1,4 +1,4 @@
1
-import { useEffect } from "react";
1
+import { useEffect, useState } from "react";
22
 import {
33
   Panel,
44
   PanelGroup,
@@ -7,19 +7,22 @@ import {
77
 
88
 import { ProjectsPane } from "@/components/ProjectsPane";
99
 import { ViewerPane } from "@/components/ViewerPane";
10
+import { relativeTime, tildeify } from "@/lib/format";
1011
 import { useSessionStore } from "@/lib/store/sessions";
1112
 
1213
 export default function App() {
1314
   const loadProjects = useSessionStore((s) => s.loadProjects);
1415
   const subscribe = useSessionStore((s) => s.subscribeToChanges);
1516
   const subscribeChat = useSessionStore((s) => s.subscribeToChatEvents);
17
+  const subscribePty = useSessionStore((s) => s.subscribeToPtyEvents);
1618
   const error = useSessionStore((s) => s.error);
1719
 
1820
   useEffect(() => {
1921
     void loadProjects();
2022
     void subscribe();
2123
     void subscribeChat();
22
-  }, [loadProjects, subscribe, subscribeChat]);
24
+    void subscribePty();
25
+  }, [loadProjects, subscribe, subscribeChat, subscribePty]);
2326
 
2427
   return (
2528
     <div className="flex h-screen flex-col bg-bg-0 text-fg-1">
@@ -57,7 +60,132 @@ function TitleBar() {
5760
         <div className="size-2 rounded-full bg-accent" />
5861
         <span className="text-sm font-medium text-fg-0">claudex</span>
5962
       </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
+      )}
61189
     </div>
62190
   );
63191
 }