groupchat 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2756 +0,0 @@
1
- import {
2
- getConfig,
3
- getCurrentToken,
4
- isAuthenticated,
5
- login,
6
- logout
7
- } from "./chunk-EF672XXZ.js";
8
-
9
- // src/components/App.tsx
10
- import { useState as useState9, useEffect as useEffect10, useCallback as useCallback6, useRef as useRef5 } from "react";
11
- import { Box as Box13, useApp, useInput as useInput5, useStdout as useStdout5 } from "ink";
12
-
13
- // src/components/LoginScreen.tsx
14
- import { useEffect } from "react";
15
- import { Box, Text, useInput, useStdout } from "ink";
16
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
17
- function LoginScreen({ onLogin, status, isLoading }) {
18
- const { stdout } = useStdout();
19
- useEffect(() => {
20
- if (!stdout) return;
21
- stdout.write("\x1B]0;Welcome to Groupchatty\x07");
22
- }, [stdout]);
23
- useInput((input, key) => {
24
- if (key.return && !isLoading) {
25
- onLogin();
26
- }
27
- });
28
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", padding: 2, children: [
29
- /* @__PURE__ */ jsx(Box, { marginBottom: 2, children: /* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: `
30
- \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557
31
- \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D
32
- \u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D
33
- \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
34
- \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551
35
- \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D
36
- G R O U P C H A T
37
- ` }) }),
38
- /* @__PURE__ */ jsx(
39
- Box,
40
- {
41
- borderStyle: "single",
42
- borderColor: "redBright",
43
- paddingX: 4,
44
- paddingY: 1,
45
- flexDirection: "column",
46
- alignItems: "center",
47
- children: isLoading ? /* @__PURE__ */ jsxs(Fragment, { children: [
48
- /* @__PURE__ */ jsx(Text, { color: "yellow", children: status || "Authenticating..." }),
49
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Please complete login in your browser..." }) })
50
- ] }) : status ? /* @__PURE__ */ jsxs(Fragment, { children: [
51
- /* @__PURE__ */ jsx(Text, { color: "red", children: status }),
52
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
53
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press " }),
54
- /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "Enter" }),
55
- /* @__PURE__ */ jsx(Text, { color: "gray", children: " to try again" })
56
- ] })
57
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
58
- /* @__PURE__ */ jsx(Text, { color: "redBright", children: "Welcome to Groupchat!" }),
59
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
60
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press " }),
61
- /* @__PURE__ */ jsx(Text, { color: "green", bold: true, children: "Enter" }),
62
- /* @__PURE__ */ jsx(Text, { color: "gray", children: " to login with your browser" })
63
- ] })
64
- ] })
65
- }
66
- ),
67
- /* @__PURE__ */ jsx(Box, { marginTop: 2, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Ctrl+C to exit" }) })
68
- ] });
69
- }
70
-
71
- // src/components/Menu.tsx
72
- import { useState as useState2, useEffect as useEffect2, useMemo } from "react";
73
- import { Box as Box4, Text as Text3, useInput as useInput2, useStdout as useStdout2 } from "ink";
74
-
75
- // src/components/Header.tsx
76
- import { Box as Box2, Text as Text2 } from "ink";
77
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
78
- function Header({
79
- username,
80
- roomName,
81
- connectionStatus,
82
- title,
83
- showStatus = true
84
- }) {
85
- const statusColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
86
- const statusText = connectionStatus === "connected" ? "ONLINE" : connectionStatus === "connecting" ? "CONNECTING" : "OFFLINE";
87
- return /* @__PURE__ */ jsxs2(
88
- Box2,
89
- {
90
- borderStyle: "single",
91
- borderColor: "gray",
92
- paddingX: 1,
93
- justifyContent: "space-between",
94
- width: "100%",
95
- flexShrink: 0,
96
- children: [
97
- /* @__PURE__ */ jsx2(Box2, { children: title || /* @__PURE__ */ jsxs2(Fragment2, { children: [
98
- /* @__PURE__ */ jsxs2(Text2, { color: "cyan", bold: true, children: [
99
- "$",
100
- " "
101
- ] }),
102
- /* @__PURE__ */ jsx2(Text2, { color: "blue", bold: true, children: "groupchat" }),
103
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " --session " }),
104
- /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: username || "..." })
105
- ] }) }),
106
- /* @__PURE__ */ jsxs2(Box2, { children: [
107
- showStatus && /* @__PURE__ */ jsxs2(Fragment2, { children: [
108
- /* @__PURE__ */ jsxs2(Text2, { color: statusColor, children: [
109
- "[",
110
- statusText,
111
- "]"
112
- ] }),
113
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " " })
114
- ] }),
115
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[Ctrl+O: LOGOUT]" })
116
- ] })
117
- ]
118
- }
119
- );
120
- }
121
-
122
- // src/components/Layout.tsx
123
- import { Children, isValidElement } from "react";
124
- import { Box as Box3 } from "ink";
125
- import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
126
- function LayoutHeader({ children }) {
127
- return /* @__PURE__ */ jsx3(Fragment3, { children });
128
- }
129
- function LayoutContent({ children }) {
130
- return /* @__PURE__ */ jsx3(Fragment3, { children });
131
- }
132
- function extractSlot(children, SlotComponent) {
133
- let slotContent = null;
134
- Children.forEach(children, (child) => {
135
- if (isValidElement(child) && child.type === SlotComponent) {
136
- slotContent = child.props.children;
137
- }
138
- });
139
- return slotContent;
140
- }
141
- function Layout({ width, height, topPadding = 0, children }) {
142
- const header = extractSlot(children, LayoutHeader);
143
- const content = extractSlot(children, LayoutContent);
144
- return /* @__PURE__ */ jsxs3(
145
- Box3,
146
- {
147
- flexDirection: "column",
148
- width,
149
- height,
150
- overflow: "hidden",
151
- paddingTop: topPadding,
152
- children: [
153
- header,
154
- content
155
- ]
156
- }
157
- );
158
- }
159
- Layout.Header = LayoutHeader;
160
- Layout.Content = LayoutContent;
161
-
162
- // src/routes/Router.tsx
163
- import { createContext, useContext, useState, useCallback } from "react";
164
- import { jsx as jsx4 } from "react/jsx-runtime";
165
- var NavigationContext = createContext(null);
166
- function useNavigation() {
167
- const context = useContext(NavigationContext);
168
- if (!context) {
169
- throw new Error("useNavigation must be used within a Router");
170
- }
171
- return context;
172
- }
173
- function Router({ initialRoute = "menu", children }) {
174
- const [route, setRoute] = useState(initialRoute);
175
- const [history, setHistory] = useState([initialRoute]);
176
- const navigate = useCallback((to) => {
177
- setRoute(to);
178
- setHistory((prev) => [...prev, to]);
179
- }, []);
180
- const goBack = useCallback(() => {
181
- setHistory((prev) => {
182
- if (prev.length <= 1) return prev;
183
- const newHistory = prev.slice(0, -1);
184
- const previousRoute = newHistory[newHistory.length - 1];
185
- setRoute(previousRoute);
186
- return newHistory;
187
- });
188
- }, []);
189
- return /* @__PURE__ */ jsx4(NavigationContext.Provider, { value: { route, navigate, goBack }, children });
190
- }
191
-
192
- // src/components/Menu.tsx
193
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
194
- function Menu({
195
- width,
196
- height,
197
- currentChannel,
198
- onChannelSelect,
199
- username,
200
- connectionStatus,
201
- onLogout,
202
- topPadding = 0,
203
- publicChannels,
204
- privateChannels,
205
- unreadCounts
206
- }) {
207
- const { stdout } = useStdout2();
208
- const { navigate } = useNavigation();
209
- const sortedPublicChannels = useMemo(() => {
210
- return [...publicChannels].sort((a, b) => a.id.localeCompare(b.id));
211
- }, [publicChannels]);
212
- const allChannels = useMemo(() => {
213
- return [...sortedPublicChannels, ...privateChannels];
214
- }, [sortedPublicChannels, privateChannels]);
215
- const menuItems = useMemo(() => {
216
- const items = allChannels.map((channel) => ({
217
- type: "channel",
218
- channel
219
- }));
220
- items.push({
221
- type: "action",
222
- action: "create-channel",
223
- label: "Create New Private Channel"
224
- });
225
- return items;
226
- }, [allChannels]);
227
- const [selectedIndex, setSelectedIndex] = useState2(0);
228
- useEffect2(() => {
229
- if (menuItems.length > 0) {
230
- const currentIndex = menuItems.findIndex(
231
- (item) => item.type === "channel" && item.channel.slug === currentChannel
232
- );
233
- setSelectedIndex(currentIndex >= 0 ? currentIndex : 0);
234
- }
235
- }, [menuItems, currentChannel]);
236
- useEffect2(() => {
237
- if (!stdout) return;
238
- stdout.write(`\x1B]0;Menu\x07`);
239
- }, [stdout]);
240
- useInput2((input, key) => {
241
- if (key.escape) {
242
- navigate("chat");
243
- return;
244
- }
245
- if (key.upArrow) {
246
- setSelectedIndex((prev) => Math.max(0, prev - 1));
247
- return;
248
- }
249
- if (key.downArrow) {
250
- setSelectedIndex((prev) => Math.min(menuItems.length - 1, prev + 1));
251
- return;
252
- }
253
- if (key.return && menuItems.length > 0) {
254
- const selected = menuItems[selectedIndex];
255
- if (selected) {
256
- if (selected.type === "channel") {
257
- onChannelSelect(selected.channel.slug);
258
- navigate("chat");
259
- } else if (selected.type === "action" && selected.action === "create-channel") {
260
- navigate("create-channel");
261
- }
262
- }
263
- }
264
- });
265
- const headerHeight = 3;
266
- const contentHeight = height - topPadding - headerHeight;
267
- const privateStartIndex = sortedPublicChannels.length;
268
- return /* @__PURE__ */ jsxs4(Layout, { width, height, topPadding, children: [
269
- /* @__PURE__ */ jsx5(Layout.Header, { children: /* @__PURE__ */ jsx5(
270
- Header,
271
- {
272
- username,
273
- roomName: "Menu",
274
- connectionStatus,
275
- onLogout,
276
- title: /* @__PURE__ */ jsx5(Text3, { bold: true, color: "cyan", children: "Menu" }),
277
- showStatus: false
278
- }
279
- ) }),
280
- /* @__PURE__ */ jsx5(Layout.Content, { children: /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", height: contentHeight, padding: 2, children: [
281
- sortedPublicChannels.length > 0 && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginBottom: 1, children: [
282
- /* @__PURE__ */ jsx5(Box4, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text3, { bold: true, color: "white", children: "Global Channels" }) }),
283
- sortedPublicChannels.map((channel, idx) => {
284
- const isSelected = selectedIndex === idx;
285
- const unreadCount = unreadCounts[channel.slug] || 0;
286
- return /* @__PURE__ */ jsx5(
287
- ChannelItem,
288
- {
289
- channel,
290
- isSelected,
291
- unreadCount
292
- },
293
- channel.id
294
- );
295
- })
296
- ] }),
297
- privateChannels.length > 0 && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginBottom: 1, children: [
298
- /* @__PURE__ */ jsx5(Box4, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text3, { bold: true, color: "white", children: "Private Channels" }) }),
299
- privateChannels.map((channel, idx) => {
300
- const absoluteIndex = privateStartIndex + idx;
301
- const isSelected = selectedIndex === absoluteIndex;
302
- const unreadCount = unreadCounts[channel.slug] || 0;
303
- return /* @__PURE__ */ jsx5(
304
- ChannelItem,
305
- {
306
- channel,
307
- isSelected,
308
- isPrivate: true,
309
- unreadCount
310
- },
311
- channel.id
312
- );
313
- })
314
- ] }),
315
- /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginBottom: 1, children: [
316
- privateChannels.length === 0 && /* @__PURE__ */ jsx5(Box4, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text3, { bold: true, color: "white", children: "Private Channels" }) }),
317
- /* @__PURE__ */ jsx5(
318
- ActionItem,
319
- {
320
- label: "+ Create New Private Channel",
321
- isSelected: selectedIndex === allChannels.length
322
- }
323
- )
324
- ] }),
325
- allChannels.length === 0 && /* @__PURE__ */ jsx5(Box4, { children: /* @__PURE__ */ jsx5(Text3, { color: "gray", children: "No channels available" }) }),
326
- /* @__PURE__ */ jsx5(Box4, { flexGrow: 1 }),
327
- /* @__PURE__ */ jsxs4(
328
- Box4,
329
- {
330
- flexDirection: "column",
331
- borderStyle: "single",
332
- borderColor: "gray",
333
- paddingX: 1,
334
- children: [
335
- /* @__PURE__ */ jsxs4(Text3, { color: "gray", children: [
336
- /* @__PURE__ */ jsx5(Text3, { color: "cyan", children: "Up/Down" }),
337
- " Navigate channels"
338
- ] }),
339
- /* @__PURE__ */ jsxs4(Text3, { color: "gray", children: [
340
- /* @__PURE__ */ jsx5(Text3, { color: "cyan", children: "Enter" }),
341
- " Join selected channel"
342
- ] }),
343
- /* @__PURE__ */ jsxs4(Text3, { color: "gray", children: [
344
- /* @__PURE__ */ jsx5(Text3, { color: "cyan", children: "ESC" }),
345
- " Back to chat"
346
- ] }),
347
- /* @__PURE__ */ jsxs4(Text3, { color: "gray", children: [
348
- /* @__PURE__ */ jsx5(Text3, { color: "cyan", children: "Ctrl+C" }),
349
- " Exit the app"
350
- ] })
351
- ]
352
- }
353
- )
354
- ] }) })
355
- ] });
356
- }
357
- function ChannelItem({ channel, isSelected, isPrivate = false, unreadCount = 0 }) {
358
- return /* @__PURE__ */ jsxs4(Box4, { marginLeft: 2, children: [
359
- /* @__PURE__ */ jsxs4(Text3, { color: isSelected ? "green" : "white", bold: isSelected, children: [
360
- isSelected ? "> " : " ",
361
- isPrivate && /* @__PURE__ */ jsx5(Text3, { color: "yellow", children: "\u{1F512} " }),
362
- "#",
363
- channel.name || channel.slug,
364
- unreadCount > 0 && /* @__PURE__ */ jsxs4(Text3, { color: "green", bold: true, children: [
365
- " ",
366
- "(",
367
- unreadCount,
368
- ")"
369
- ] })
370
- ] }),
371
- isSelected && channel.description && /* @__PURE__ */ jsxs4(Text3, { color: "gray", dimColor: true, children: [
372
- " ",
373
- "- ",
374
- channel.description
375
- ] })
376
- ] });
377
- }
378
- function ActionItem({ label, isSelected }) {
379
- return /* @__PURE__ */ jsx5(Box4, { marginLeft: 2, children: /* @__PURE__ */ jsxs4(Text3, { color: isSelected ? "green" : "cyan", bold: isSelected, children: [
380
- isSelected ? "> " : " ",
381
- label
382
- ] }) });
383
- }
384
-
385
- // src/components/ChatView.tsx
386
- import { useEffect as useEffect5 } from "react";
387
- import { Box as Box11, Text as Text10, useStdout as useStdout3 } from "ink";
388
-
389
- // src/components/MessageList.tsx
390
- import { useMemo as useMemo2 } from "react";
391
- import { Box as Box6, Text as Text5 } from "ink";
392
-
393
- // src/components/MessageItem.tsx
394
- import { Box as Box5, Text as Text4 } from "ink";
395
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
396
- function getUsernameColor(username) {
397
- const colors = [
398
- "cyan",
399
- "magenta",
400
- "yellow",
401
- "blue",
402
- "green",
403
- "red"
404
- ];
405
- let hash = 0;
406
- for (let i = 0; i < username.length; i++) {
407
- hash = username.charCodeAt(i) + ((hash << 5) - hash);
408
- }
409
- return colors[Math.abs(hash) % colors.length];
410
- }
411
- function formatTime(timestamp) {
412
- const date = new Date(timestamp);
413
- return date.toLocaleTimeString("en-US", {
414
- hour: "2-digit",
415
- minute: "2-digit",
416
- hour12: true
417
- });
418
- }
419
- function MessageItem({ message, isOwnMessage }) {
420
- const time = formatTime(message.timestamp);
421
- const usernameColor = getUsernameColor(message.username);
422
- if (isOwnMessage) {
423
- return /* @__PURE__ */ jsx6(Box5, { justifyContent: "flex-end", paddingY: 0, children: /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", alignItems: "flex-end", children: [
424
- /* @__PURE__ */ jsxs5(Box5, { children: [
425
- /* @__PURE__ */ jsxs5(Text4, { color: "gray", children: [
426
- "[",
427
- time,
428
- "] "
429
- ] }),
430
- /* @__PURE__ */ jsx6(Text4, { color: usernameColor, bold: true, children: message.username }),
431
- /* @__PURE__ */ jsx6(Text4, { color: "gray", children: " \u2192" })
432
- ] }),
433
- /* @__PURE__ */ jsx6(Box5, { paddingLeft: 2, children: /* @__PURE__ */ jsx6(Text4, { children: message.content }) })
434
- ] }) });
435
- }
436
- return /* @__PURE__ */ jsx6(Box5, { justifyContent: "flex-start", paddingY: 0, children: /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
437
- /* @__PURE__ */ jsxs5(Box5, { children: [
438
- /* @__PURE__ */ jsx6(Text4, { color: "gray", children: "\u2190 " }),
439
- /* @__PURE__ */ jsx6(Text4, { color: usernameColor, bold: true, children: message.username }),
440
- /* @__PURE__ */ jsxs5(Text4, { color: "gray", children: [
441
- " [",
442
- time,
443
- "]"
444
- ] })
445
- ] }),
446
- /* @__PURE__ */ jsx6(Box5, { paddingLeft: 2, children: /* @__PURE__ */ jsx6(Text4, { children: message.content }) })
447
- ] }) });
448
- }
449
-
450
- // src/components/MessageList.tsx
451
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
452
- function MessageList({
453
- messages,
454
- currentUsername,
455
- typingUsers,
456
- height,
457
- scrollOffset,
458
- isDetached
459
- }) {
460
- const othersTyping = typingUsers.filter((u) => u !== currentUsername);
461
- const visibleMessages = useMemo2(() => {
462
- const linesPerMessage = 2;
463
- const maxMessages = Math.floor(height / linesPerMessage);
464
- const endIndex = messages.length - scrollOffset;
465
- const startIndex = Math.max(0, endIndex - maxMessages);
466
- return messages.slice(startIndex, endIndex);
467
- }, [messages, height, scrollOffset]);
468
- return /* @__PURE__ */ jsxs6(
469
- Box6,
470
- {
471
- flexDirection: "column",
472
- height,
473
- paddingX: 1,
474
- overflow: "hidden",
475
- children: [
476
- /* @__PURE__ */ jsx7(Box6, { flexGrow: 1 }),
477
- messages.length === 0 ? /* @__PURE__ */ jsx7(Box6, { justifyContent: "center", paddingY: 2, children: /* @__PURE__ */ jsx7(Text5, { color: "gray", children: "No messages yet. Say hello!" }) }) : visibleMessages.map((message) => /* @__PURE__ */ jsx7(
478
- MessageItem,
479
- {
480
- message,
481
- isOwnMessage: message.username === currentUsername
482
- },
483
- message.id
484
- )),
485
- isDetached && /* @__PURE__ */ jsx7(Box6, { justifyContent: "center", children: /* @__PURE__ */ jsxs6(Text5, { color: "yellow", bold: true, children: [
486
- "-- ",
487
- scrollOffset,
488
- " more below (\u2193 to scroll down) --"
489
- ] }) }),
490
- othersTyping.length > 0 && !isDetached && /* @__PURE__ */ jsx7(Box6, { paddingTop: 1, children: /* @__PURE__ */ jsx7(Text5, { color: "gray", italic: true, children: othersTyping.length === 1 ? `${othersTyping[0]} is typing...` : `${othersTyping.join(", ")} are typing...` }) })
491
- ]
492
- }
493
- );
494
- }
495
-
496
- // src/components/UserList.tsx
497
- import { Box as Box7, Text as Text6 } from "ink";
498
- import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
499
- function UserList({
500
- users,
501
- currentUsername,
502
- height,
503
- isPrivateChannel = false
504
- }) {
505
- const onlineCount = users.filter((u) => u.isOnline).length;
506
- const offlineCount = users.filter((u) => !u.isOnline).length;
507
- const sortedUsers = [...users].sort((a, b) => {
508
- if (a.username === currentUsername) return -1;
509
- if (b.username === currentUsername) return 1;
510
- if (a.isOnline && !b.isOnline) return -1;
511
- if (!a.isOnline && b.isOnline) return 1;
512
- return 0;
513
- });
514
- return /* @__PURE__ */ jsxs7(
515
- Box7,
516
- {
517
- flexDirection: "column",
518
- flexShrink: 0,
519
- borderStyle: "single",
520
- borderColor: "gray",
521
- width: 24,
522
- height,
523
- paddingX: 1,
524
- marginBottom: 1,
525
- overflow: "hidden",
526
- children: [
527
- /* @__PURE__ */ jsx8(Box7, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsx8(Text6, { color: "white", bold: true, children: "MEMBERS" }) : /* @__PURE__ */ jsxs7(Fragment4, { children: [
528
- /* @__PURE__ */ jsx8(Text6, { color: "green", bold: true, children: "\u25CF " }),
529
- /* @__PURE__ */ jsx8(Text6, { color: "white", bold: true, children: "ONLINE USERS" })
530
- ] }) }),
531
- /* @__PURE__ */ jsx8(Box7, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsxs7(Text6, { color: "cyan", children: [
532
- "[",
533
- onlineCount,
534
- " online]"
535
- ] }) : /* @__PURE__ */ jsxs7(Text6, { color: "cyan", children: [
536
- "[",
537
- onlineCount,
538
- " connected]"
539
- ] }) }),
540
- /* @__PURE__ */ jsx8(Box7, { flexDirection: "column", children: sortedUsers.map((user) => {
541
- const isTruncated = user.username.length > 8;
542
- const displayName = isTruncated ? user.username.substring(0, 8) : user.username;
543
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
544
- /* @__PURE__ */ jsxs7(Box7, { children: [
545
- /* @__PURE__ */ jsx8(Text6, { color: user.isOnline ? "green" : "gray", children: "\u25CF" }),
546
- /* @__PURE__ */ jsx8(Text6, { children: " " }),
547
- /* @__PURE__ */ jsxs7(Text6, { color: user.username === currentUsername ? "yellow" : "white", children: [
548
- displayName,
549
- isTruncated && "\u2026"
550
- ] }),
551
- user.username === currentUsername && /* @__PURE__ */ jsx8(Text6, { color: "gray", children: " (you)" }),
552
- user.role === "admin" && /* @__PURE__ */ jsx8(Text6, { color: "yellow", children: " \u2605" })
553
- ] }),
554
- user.currentAgent && /* @__PURE__ */ jsx8(Box7, { marginLeft: 2, children: /* @__PURE__ */ jsxs7(Text6, { color: user.currentAgent === "claude" ? "redBright" : "cyan", children: [
555
- "\u2937 Using ",
556
- user.currentAgent === "claude" ? "Claude" : "Codex"
557
- ] }) })
558
- ] }, user.username);
559
- }) })
560
- ]
561
- }
562
- );
563
- }
564
-
565
- // src/components/InputBox.tsx
566
- import { useState as useState3, useCallback as useCallback2, useRef, useEffect as useEffect3 } from "react";
567
- import { Box as Box8, Text as Text7 } from "ink";
568
- import TextInput from "ink-text-input";
569
- import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
570
- function InputBox({
571
- onSend,
572
- onTypingStart,
573
- onTypingStop,
574
- disabled,
575
- onInputChange
576
- }) {
577
- const [value, setValue] = useState3("");
578
- const [isSending, setIsSending] = useState3(false);
579
- const typingTimeoutRef = useRef(null);
580
- const isTypingRef = useRef(false);
581
- const handleChange = useCallback2(
582
- (newValue) => {
583
- setValue(newValue);
584
- onInputChange?.(newValue);
585
- if (!isTypingRef.current && newValue.length > 0) {
586
- isTypingRef.current = true;
587
- onTypingStart();
588
- }
589
- if (typingTimeoutRef.current) {
590
- clearTimeout(typingTimeoutRef.current);
591
- }
592
- if (newValue.length > 0) {
593
- typingTimeoutRef.current = setTimeout(() => {
594
- isTypingRef.current = false;
595
- onTypingStop();
596
- }, 2e3);
597
- } else {
598
- isTypingRef.current = false;
599
- onTypingStop();
600
- }
601
- },
602
- [onTypingStart, onTypingStop, onInputChange]
603
- );
604
- const handleSubmit = useCallback2(async () => {
605
- const trimmed = value.trim();
606
- if (!trimmed || disabled || isSending) return;
607
- setIsSending(true);
608
- if (typingTimeoutRef.current) {
609
- clearTimeout(typingTimeoutRef.current);
610
- }
611
- isTypingRef.current = false;
612
- onTypingStop();
613
- try {
614
- await onSend(trimmed);
615
- setValue("");
616
- } catch {
617
- } finally {
618
- setIsSending(false);
619
- }
620
- }, [value, disabled, isSending, onSend, onTypingStop]);
621
- useEffect3(() => {
622
- return () => {
623
- if (typingTimeoutRef.current) {
624
- clearTimeout(typingTimeoutRef.current);
625
- }
626
- };
627
- }, []);
628
- return /* @__PURE__ */ jsxs8(
629
- Box8,
630
- {
631
- borderStyle: "single",
632
- borderColor: "gray",
633
- paddingX: 1,
634
- flexDirection: "column",
635
- width: "100%",
636
- flexShrink: 0,
637
- children: [
638
- /* @__PURE__ */ jsxs8(Box8, { children: [
639
- /* @__PURE__ */ jsx9(Text7, { color: "cyan", children: "$ " }),
640
- /* @__PURE__ */ jsx9(Box8, { flexGrow: 1, children: /* @__PURE__ */ jsx9(
641
- TextInput,
642
- {
643
- value,
644
- onChange: handleChange,
645
- onSubmit: handleSubmit,
646
- placeholder: disabled ? "Connecting..." : "Type a message..."
647
- }
648
- ) }),
649
- /* @__PURE__ */ jsxs8(Text7, { color: disabled || !value.trim() ? "gray" : "green", children: [
650
- " ",
651
- "[SEND]"
652
- ] })
653
- ] }),
654
- /* @__PURE__ */ jsx9(Box8, { children: /* @__PURE__ */ jsx9(Text7, { color: "gray", dimColor: true, children: "Enter to send" }) })
655
- ]
656
- }
657
- );
658
- }
659
-
660
- // src/components/StatusBar.tsx
661
- import { Box as Box9, Text as Text8 } from "ink";
662
- import { Fragment as Fragment5, jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
663
- function StatusBar({
664
- connectionStatus,
665
- error,
666
- userCount
667
- }) {
668
- const presenceText = connectionStatus === "connected" ? "Active" : connectionStatus === "connecting" ? "Connecting" : "Disconnected";
669
- const presenceColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
670
- return /* @__PURE__ */ jsxs9(
671
- Box9,
672
- {
673
- borderStyle: "single",
674
- borderColor: "gray",
675
- paddingX: 1,
676
- justifyContent: "space-between",
677
- width: "100%",
678
- flexShrink: 0,
679
- children: [
680
- /* @__PURE__ */ jsx10(Box9, { children: error ? /* @__PURE__ */ jsxs9(Text8, { color: "red", children: [
681
- "[Error: ",
682
- error,
683
- "]"
684
- ] }) : /* @__PURE__ */ jsxs9(Fragment5, { children: [
685
- /* @__PURE__ */ jsx10(Text8, { color: "gray", children: "\u2192 Presence: " }),
686
- /* @__PURE__ */ jsx10(Text8, { color: presenceColor, children: presenceText })
687
- ] }) }),
688
- /* @__PURE__ */ jsxs9(Box9, { children: [
689
- /* @__PURE__ */ jsx10(Text8, { color: "gray", children: "Users: " }),
690
- /* @__PURE__ */ jsx10(Text8, { color: "cyan", children: userCount }),
691
- /* @__PURE__ */ jsx10(Text8, { color: "gray", children: " | \u2191/\u2193 scroll | Ctrl+E users | Ctrl+C exit" })
692
- ] })
693
- ]
694
- }
695
- );
696
- }
697
-
698
- // src/components/ToolTip.tsx
699
- import { Box as Box10, Text as Text9 } from "ink";
700
- import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
701
- var ToolTip = ({ tips, type }) => {
702
- return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", paddingX: 1, children: [
703
- /* @__PURE__ */ jsx11(Text9, { children: " " }),
704
- type === "Command" && tips.map((tip) => /* @__PURE__ */ jsxs10(Text9, { color: "gray", children: [
705
- /* @__PURE__ */ jsx11(Text9, { color: "cyan", children: tip.syntax }),
706
- " - ",
707
- tip.description
708
- ] }, tip.name)),
709
- type === "User" && tips.map((suggestion) => /* @__PURE__ */ jsx11(Text9, { color: "gray", children: /* @__PURE__ */ jsx11(Text9, { color: "cyan", children: suggestion }) }, suggestion))
710
- ] });
711
- };
712
-
713
- // src/hooks/use-command-input.ts
714
- import { useMemo as useMemo4, useState as useState5 } from "react";
715
-
716
- // src/lib/commands.ts
717
- var parameterValidators = {
718
- username: (value, param, ctx) => {
719
- const p = param;
720
- if (p.source === "search") {
721
- if (!value.startsWith(p.prefix)) {
722
- return {
723
- isValid: false,
724
- error: `Must start with ${p.prefix}`,
725
- suggestions: []
726
- };
727
- }
728
- const username2 = value.substring(p.prefix.length);
729
- const results = ctx.asyncSearchResults || [];
730
- if (!username2) {
731
- return {
732
- isValid: false,
733
- error: "Username required",
734
- suggestions: results.map((r) => `${p.prefix}${r.username}`)
735
- };
736
- }
737
- const matching = results.filter(
738
- (r) => r.username.toLowerCase().startsWith(username2.toLowerCase())
739
- );
740
- const exactMatch2 = results.find(
741
- (r) => r.username.toLowerCase() === username2.toLowerCase()
742
- );
743
- return {
744
- isValid: results.length > 0 && !!exactMatch2,
745
- error: results.length === 0 ? "User not found" : exactMatch2 ? void 0 : "User not found",
746
- suggestions: matching.map((r) => `${p.prefix}${r.username}`)
747
- };
748
- }
749
- const getUserList = () => {
750
- switch (p.source) {
751
- case "subscribed_without_self":
752
- return ctx.subscribedUsers.filter((u) => u.username !== ctx.currentUsername);
753
- case "not_subscribed": {
754
- const subscribedIds = new Set(ctx.subscribedUsers.map((u) => u.user_id));
755
- return ctx.presentUsers.filter((u) => !subscribedIds.has(u.user_id));
756
- }
757
- case "all":
758
- default:
759
- return ctx.presentUsers;
760
- }
761
- };
762
- const users = getUserList();
763
- if (!value.startsWith(p.prefix)) {
764
- return {
765
- isValid: false,
766
- error: `Must start with ${p.prefix}`,
767
- suggestions: users.map((u) => `${p.prefix}${u.username}`)
768
- };
769
- }
770
- const username = value.substring(p.prefix.length);
771
- if (!username) {
772
- return {
773
- isValid: false,
774
- error: "Username required",
775
- suggestions: users.map((u) => `${p.prefix}${u.username}`)
776
- };
777
- }
778
- const matchingUsers = users.filter(
779
- (u) => u.username.toLowerCase().startsWith(username.toLowerCase())
780
- );
781
- const exactMatch = users.find(
782
- (u) => u.username.toLowerCase() === username.toLowerCase()
783
- );
784
- return {
785
- isValid: !!exactMatch,
786
- error: exactMatch ? void 0 : "User not found",
787
- suggestions: matchingUsers.map((u) => `${p.prefix}${u.username}`)
788
- };
789
- },
790
- text: (value, param) => {
791
- const p = param;
792
- if (p.minLength && value.length < p.minLength) {
793
- return { isValid: false, error: `Minimum ${p.minLength} characters` };
794
- }
795
- if (p.maxLength && value.length > p.maxLength) {
796
- return { isValid: false, error: `Maximum ${p.maxLength} characters` };
797
- }
798
- return { isValid: true };
799
- },
800
- number: (value, param) => {
801
- const p = param;
802
- const num = Number(value);
803
- if (isNaN(num)) {
804
- return { isValid: false, error: "Must be a number" };
805
- }
806
- if (p.min !== void 0 && num < p.min) {
807
- return { isValid: false, error: `Minimum value is ${p.min}` };
808
- }
809
- if (p.max !== void 0 && num > p.max) {
810
- return { isValid: false, error: `Maximum value is ${p.max}` };
811
- }
812
- return { isValid: true };
813
- },
814
- choice: (value, param) => {
815
- const p = param;
816
- const matching = p.choices.filter(
817
- (c) => c.toLowerCase().startsWith(value.toLowerCase())
818
- );
819
- const exactMatch = p.choices.find(
820
- (c) => c.toLowerCase() === value.toLowerCase()
821
- );
822
- return {
823
- isValid: !!exactMatch,
824
- error: exactMatch ? void 0 : `Must be one of: ${p.choices.join(", ")}`,
825
- suggestions: matching
826
- };
827
- }
828
- };
829
- var COMMANDS = [
830
- {
831
- name: "/invite",
832
- syntax: "/invite @user",
833
- description: "Invite a user to join the channel",
834
- privateOnly: true,
835
- adminOnly: true,
836
- parameters: [
837
- { name: "user", type: "username", required: true, prefix: "@", source: "search" }
838
- ],
839
- eventType: "invite_user"
840
- },
841
- {
842
- name: "/remove",
843
- syntax: "/remove @user",
844
- description: "Remove a user from the channel",
845
- privateOnly: true,
846
- adminOnly: true,
847
- parameters: [
848
- { name: "user", type: "username", required: true, prefix: "@", source: "subscribed_without_self" }
849
- ],
850
- eventType: "remove_user"
851
- }
852
- // Easy to add more commands:
853
- // {
854
- // name: "/topic",
855
- // syntax: "/topic <text>",
856
- // description: "Set the channel topic",
857
- // privateOnly: false,
858
- // parameters: [
859
- // { name: "topic", type: "text", required: true, minLength: 1, maxLength: 200 },
860
- // ],
861
- // eventType: "set_topic",
862
- // },
863
- ];
864
-
865
- // src/lib/command-parser.ts
866
- function parseCommandInput(input, commands, ctx) {
867
- const empty = {
868
- command: null,
869
- phase: "none",
870
- parameterValues: /* @__PURE__ */ new Map(),
871
- parameterResults: /* @__PURE__ */ new Map(),
872
- isValid: true
873
- };
874
- if (!input.startsWith("/")) {
875
- return empty;
876
- }
877
- const spaceIndex = input.indexOf(" ");
878
- const commandText = spaceIndex === -1 ? input : input.substring(0, spaceIndex);
879
- const command = commands.find((cmd) => cmd.name === commandText);
880
- if (!command) {
881
- return {
882
- ...empty,
883
- phase: "command"
884
- };
885
- }
886
- if (spaceIndex === -1) {
887
- const hasRequiredParams = command.parameters.some((p) => p.required);
888
- return {
889
- command,
890
- phase: "command",
891
- parameterValues: /* @__PURE__ */ new Map(),
892
- parameterResults: /* @__PURE__ */ new Map(),
893
- isValid: !hasRequiredParams
894
- // Valid only if no required params
895
- };
896
- }
897
- const paramInput = input.substring(spaceIndex + 1);
898
- const parameterValues = /* @__PURE__ */ new Map();
899
- const parameterResults = /* @__PURE__ */ new Map();
900
- if (command.parameters.length > 0) {
901
- const param = command.parameters[0];
902
- parameterValues.set(param.name, paramInput);
903
- if (paramInput) {
904
- const result = parameterValidators[param.type](paramInput, param, ctx);
905
- parameterResults.set(param.name, result);
906
- }
907
- }
908
- let isValid = true;
909
- let error;
910
- for (const param of command.parameters) {
911
- const value = parameterValues.get(param.name) || "";
912
- const result = parameterResults.get(param.name);
913
- if (param.required && !value) {
914
- isValid = false;
915
- error = `${param.name} is required`;
916
- break;
917
- }
918
- if (value && result && !result.isValid) {
919
- isValid = false;
920
- error = result.error;
921
- break;
922
- }
923
- }
924
- return {
925
- command,
926
- phase: "parameter",
927
- parameterValues,
928
- parameterResults,
929
- isValid,
930
- error
931
- };
932
- }
933
- function getSuggestions(input, commands, parsed) {
934
- if (parsed.phase === "command" || input.startsWith("/") && !parsed.command) {
935
- const filtered = commands.filter((cmd) => {
936
- if (input.length > 1) {
937
- return cmd.name.startsWith(input.split(" ")[0]);
938
- }
939
- return true;
940
- });
941
- if (filtered.length > 0) {
942
- return { type: "commands", commands: filtered };
943
- }
944
- return null;
945
- }
946
- if (parsed.phase === "parameter" && parsed.command) {
947
- const param = parsed.command.parameters[0];
948
- if (param) {
949
- const result = parsed.parameterResults.get(param.name);
950
- if (result?.suggestions && result.suggestions.length > 0) {
951
- return {
952
- type: "parameter",
953
- parameterSuggestions: result.suggestions,
954
- parameterName: param.name
955
- };
956
- }
957
- }
958
- }
959
- return null;
960
- }
961
- function extractCommandPayload(parsed, ctx) {
962
- if (!parsed.command || !parsed.isValid) {
963
- return null;
964
- }
965
- const data = {};
966
- for (const param of parsed.command.parameters) {
967
- const value = parsed.parameterValues.get(param.name);
968
- if (!value) continue;
969
- switch (param.type) {
970
- case "username": {
971
- const p = param;
972
- const username = value.substring(p.prefix.length);
973
- let user;
974
- if (p.source === "search" && ctx.asyncSearchResults) {
975
- user = ctx.asyncSearchResults.find(
976
- (u) => u.username.toLowerCase() === username.toLowerCase()
977
- );
978
- }
979
- if (!user) {
980
- const presentUsers = [...ctx.presentUsers, ...ctx.subscribedUsers];
981
- user = presentUsers.find(
982
- (u) => u.username.toLowerCase() === username.toLowerCase()
983
- );
984
- }
985
- data.username = user?.username || username;
986
- data.user_id = user?.user_id;
987
- break;
988
- }
989
- case "text":
990
- data[param.name] = value;
991
- break;
992
- case "number":
993
- data[param.name] = Number(value);
994
- break;
995
- case "choice":
996
- data[param.name] = value;
997
- break;
998
- }
999
- }
1000
- return {
1001
- eventType: parsed.command.eventType,
1002
- data
1003
- };
1004
- }
1005
-
1006
- // src/hooks/use-user-search.ts
1007
- import { useState as useState4, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef2 } from "react";
1008
-
1009
- // src/lib/chat-client.ts
1010
- import { Socket } from "phoenix";
1011
- if (typeof globalThis.WebSocket === "undefined") {
1012
- throw new Error(
1013
- "WebSocket is not available. Load the ws polyfill before ChatClient."
1014
- );
1015
- }
1016
- async function fetchChannels(wsUrl, token) {
1017
- const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1018
- const url = `${backendUrl}/api/channels`;
1019
- const response = await fetch(url, {
1020
- headers: {
1021
- Authorization: `Bearer ${token}`
1022
- }
1023
- });
1024
- if (!response.ok) {
1025
- throw new Error(`Failed to fetch channels: ${response.status}`);
1026
- }
1027
- return response.json();
1028
- }
1029
- async function fetchUnreadCounts(wsUrl, token) {
1030
- const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1031
- const url = `${backendUrl}/api/unread-counts`;
1032
- const response = await fetch(url, {
1033
- headers: {
1034
- Authorization: `Bearer ${token}`
1035
- }
1036
- });
1037
- if (!response.ok) {
1038
- throw new Error(`Failed to fetch unread counts: ${response.status}`);
1039
- }
1040
- const data = await response.json();
1041
- return data.unread_counts || {};
1042
- }
1043
- async function searchUsers(wsUrl, token, startsWith, channelSlug, limit = 20) {
1044
- const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1045
- const encodedStartsWith = encodeURIComponent(startsWith);
1046
- const params = new URLSearchParams({
1047
- startsWith: encodedStartsWith,
1048
- limit: limit.toString()
1049
- });
1050
- if (channelSlug) {
1051
- params.append("channel_slug", encodeURIComponent(channelSlug));
1052
- }
1053
- const url = `${backendUrl}/api/users/search?${params.toString()}`;
1054
- const response = await fetch(url, {
1055
- headers: {
1056
- Authorization: `Bearer ${token}`
1057
- }
1058
- });
1059
- if (!response.ok) {
1060
- throw new Error(`Failed to search users: ${response.status}`);
1061
- }
1062
- return response.json();
1063
- }
1064
- async function createChannel(wsUrl, token, name, description) {
1065
- const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1066
- const url = `${backendUrl}/api/channels`;
1067
- const body = { name };
1068
- if (description) {
1069
- body.description = description;
1070
- }
1071
- const response = await fetch(url, {
1072
- method: "POST",
1073
- headers: {
1074
- Authorization: `Bearer ${token}`,
1075
- "Content-Type": "application/json"
1076
- },
1077
- body: JSON.stringify(body)
1078
- });
1079
- if (!response.ok) {
1080
- const data = await response.json();
1081
- throw new Error(data.error || `Failed to create channel: ${response.status}`);
1082
- }
1083
- return response.json();
1084
- }
1085
-
1086
- // src/lib/debounce.ts
1087
- function debounce(func, wait) {
1088
- let timeout = null;
1089
- return (...args) => {
1090
- if (timeout) clearTimeout(timeout);
1091
- timeout = setTimeout(() => func(...args), wait);
1092
- };
1093
- }
1094
-
1095
- // src/hooks/use-user-search.ts
1096
- function useUserSearch(token, query, channelSlug) {
1097
- const [suggestions, setSuggestions] = useState4([]);
1098
- const [results, setResults] = useState4([]);
1099
- const [isLoading, setIsLoading] = useState4(false);
1100
- const cacheRef = useRef2(/* @__PURE__ */ new Map());
1101
- const { wsUrl } = getConfig();
1102
- const debouncedSearch = useMemo3(
1103
- () => debounce(async (q, slug) => {
1104
- if (!token) return;
1105
- const cacheKey = `${q}:${slug}`;
1106
- if (cacheRef.current.has(cacheKey)) {
1107
- const cached = cacheRef.current.get(cacheKey);
1108
- setResults(cached);
1109
- setSuggestions(cached.map((u) => `@${u.username}`));
1110
- return;
1111
- }
1112
- setIsLoading(true);
1113
- try {
1114
- const result = await searchUsers(wsUrl, token, q, slug);
1115
- cacheRef.current.set(cacheKey, result.users);
1116
- setResults(result.users);
1117
- setSuggestions(result.users.map((u) => `@${u.username}`));
1118
- } catch (err) {
1119
- console.error("User search failed:", err);
1120
- setResults([]);
1121
- setSuggestions([]);
1122
- } finally {
1123
- setIsLoading(false);
1124
- }
1125
- }, 300),
1126
- [wsUrl, token]
1127
- );
1128
- useEffect4(() => {
1129
- if (query && query.length > 0 && channelSlug) {
1130
- debouncedSearch(query, channelSlug);
1131
- } else {
1132
- setResults([]);
1133
- setSuggestions([]);
1134
- cacheRef.current.clear();
1135
- }
1136
- }, [query, channelSlug, debouncedSearch]);
1137
- return { suggestions, results, isLoading };
1138
- }
1139
-
1140
- // src/hooks/use-command-input.ts
1141
- function useCommandInput({
1142
- token,
1143
- currentChannel,
1144
- isPrivateChannel,
1145
- connectionStatus,
1146
- username,
1147
- users,
1148
- subscribers,
1149
- onSendMessage,
1150
- onCommandSend
1151
- }) {
1152
- const [inputValue, setInputValue] = useState5("");
1153
- const isChannelAdmin = useMemo4(
1154
- () => subscribers.some((s) => s.username === username && s.role === "admin"),
1155
- [subscribers, username]
1156
- );
1157
- const availableCommands = useMemo4(
1158
- () => COMMANDS.filter((cmd) => {
1159
- if (cmd.privateOnly && !isPrivateChannel) return false;
1160
- if (cmd.adminOnly && !isChannelAdmin) return false;
1161
- return true;
1162
- }),
1163
- [isPrivateChannel, isChannelAdmin]
1164
- );
1165
- const baseContext = useMemo4(
1166
- () => ({
1167
- presentUsers: users.map((u) => ({ username: u.username, user_id: u.user_id })),
1168
- subscribedUsers: subscribers.map((s) => ({ username: s.username, user_id: s.user_id })),
1169
- currentUsername: username
1170
- }),
1171
- [users, subscribers, username]
1172
- );
1173
- const parsedWithoutAsync = useMemo4(
1174
- () => parseCommandInput(inputValue, availableCommands, baseContext),
1175
- [inputValue, availableCommands, baseContext]
1176
- );
1177
- const inviteQuery = useMemo4(() => {
1178
- if (parsedWithoutAsync.command?.name === "/invite" && parsedWithoutAsync.phase === "parameter") {
1179
- const raw = parsedWithoutAsync.parameterValues.get("user") || "";
1180
- return raw.replace(/^@/, "");
1181
- }
1182
- return null;
1183
- }, [parsedWithoutAsync]);
1184
- const { suggestions: asyncSuggestions, results: asyncResults } = useUserSearch(
1185
- token,
1186
- inviteQuery,
1187
- isPrivateChannel ? currentChannel : null
1188
- );
1189
- const validationContext = useMemo4(
1190
- () => ({
1191
- ...baseContext,
1192
- asyncSearchResults: asyncResults.length > 0 ? asyncResults : void 0
1193
- }),
1194
- [baseContext, asyncResults]
1195
- );
1196
- const parsed = useMemo4(
1197
- () => parseCommandInput(inputValue, availableCommands, validationContext),
1198
- [inputValue, availableCommands, validationContext]
1199
- );
1200
- const suggestionResult = useMemo4(() => {
1201
- const isCommandLike = inputValue.startsWith("/") || inputValue.startsWith("?");
1202
- if (!isCommandLike) return null;
1203
- if (parsed.command?.name === "/invite" && parsed.phase === "parameter" && asyncSuggestions.length) {
1204
- return { type: "parameter", parameterSuggestions: asyncSuggestions };
1205
- }
1206
- return getSuggestions(inputValue, availableCommands, parsed);
1207
- }, [inputValue, availableCommands, parsed, asyncSuggestions]);
1208
- const tooltip = useMemo4(() => {
1209
- if (!suggestionResult) {
1210
- return { show: false, tips: [], type: "Command", height: 0 };
1211
- }
1212
- if (suggestionResult.type === "commands" && suggestionResult.commands) {
1213
- const tips = suggestionResult.commands;
1214
- return { show: true, tips, type: "Command", height: tips.length + 1 };
1215
- }
1216
- if (suggestionResult.type === "parameter" && suggestionResult.parameterSuggestions) {
1217
- const tips = suggestionResult.parameterSuggestions;
1218
- return { show: true, tips, type: "User", height: tips.length + 1 };
1219
- }
1220
- return { show: false, tips: [], type: "Command", height: 0 };
1221
- }, [suggestionResult]);
1222
- const isInputDisabled = connectionStatus !== "connected" || parsed.command !== null && !parsed.isValid;
1223
- const handleInputChange = (value) => {
1224
- setInputValue(value);
1225
- };
1226
- const handleSubmit = async (text) => {
1227
- const parsedForSend = parseCommandInput(text, availableCommands, {
1228
- ...validationContext,
1229
- asyncSearchResults: asyncResults.length > 0 ? asyncResults : void 0
1230
- });
1231
- if (parsedForSend.command && parsedForSend.isValid) {
1232
- const payload = extractCommandPayload(parsedForSend, validationContext);
1233
- if (payload) {
1234
- await onCommandSend(payload.eventType, payload.data);
1235
- setInputValue("");
1236
- return;
1237
- }
1238
- }
1239
- await onSendMessage(text);
1240
- setInputValue("");
1241
- };
1242
- return {
1243
- inputValue,
1244
- parsed,
1245
- tooltip,
1246
- isInputDisabled,
1247
- handleInputChange,
1248
- handleSubmit
1249
- };
1250
- }
1251
-
1252
- // src/components/ChatView.tsx
1253
- import { Fragment as Fragment6, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
1254
- function ChatView({
1255
- terminalSize,
1256
- currentChannel,
1257
- channelName,
1258
- channelDescription,
1259
- connectionStatus,
1260
- username,
1261
- onLogout,
1262
- messages,
1263
- typingUsers,
1264
- middleSectionHeight,
1265
- scrollOffset,
1266
- isDetached,
1267
- showUserList,
1268
- users,
1269
- subscribers,
1270
- isPrivateChannel = false,
1271
- topPadding = 0,
1272
- onSend,
1273
- onTypingStart,
1274
- onTypingStop,
1275
- onCommandSend,
1276
- error,
1277
- token
1278
- }) {
1279
- const { stdout } = useStdout3();
1280
- const { tooltip, isInputDisabled, handleInputChange, handleSubmit } = useCommandInput({
1281
- token,
1282
- currentChannel,
1283
- isPrivateChannel,
1284
- connectionStatus,
1285
- username,
1286
- users,
1287
- subscribers,
1288
- onSendMessage: onSend,
1289
- onCommandSend
1290
- });
1291
- const displayName = channelName || currentChannel;
1292
- const displayText = channelDescription ? `${displayName} - ${channelDescription}` : displayName;
1293
- useEffect5(() => {
1294
- if (!stdout) return;
1295
- const prefix = connectionStatus === "connected" ? "\u2022 " : "";
1296
- stdout.write(`\x1B]0;${prefix}#${displayName}\x07`);
1297
- }, [stdout, connectionStatus, displayName]);
1298
- return /* @__PURE__ */ jsxs11(Layout, { width: terminalSize.columns, height: terminalSize.rows, topPadding, children: [
1299
- /* @__PURE__ */ jsx12(Layout.Header, { children: /* @__PURE__ */ jsx12(
1300
- Header,
1301
- {
1302
- username,
1303
- roomName: currentChannel,
1304
- connectionStatus,
1305
- onLogout,
1306
- title: /* @__PURE__ */ jsxs11(Fragment6, { children: [
1307
- /* @__PURE__ */ jsx12(Text10, { color: "gray", children: "\u2190 Menu " }),
1308
- /* @__PURE__ */ jsx12(Text10, { color: "gray", dimColor: true, children: "[CTRL+Q]" }),
1309
- /* @__PURE__ */ jsx12(Text10, { color: "gray", children: " | " }),
1310
- /* @__PURE__ */ jsxs11(Text10, { color: "cyan", bold: true, children: [
1311
- "#",
1312
- displayText
1313
- ] })
1314
- ] })
1315
- }
1316
- ) }),
1317
- /* @__PURE__ */ jsxs11(Layout.Content, { children: [
1318
- /* @__PURE__ */ jsxs11(
1319
- Box11,
1320
- {
1321
- flexDirection: "row",
1322
- height: Math.max(1, middleSectionHeight - tooltip.height),
1323
- overflow: "hidden",
1324
- children: [
1325
- /* @__PURE__ */ jsx12(Box11, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx12(
1326
- MessageList,
1327
- {
1328
- messages,
1329
- currentUsername: username,
1330
- typingUsers,
1331
- height: Math.max(1, middleSectionHeight - tooltip.height),
1332
- scrollOffset,
1333
- isDetached
1334
- }
1335
- ) }),
1336
- showUserList && /* @__PURE__ */ jsx12(
1337
- UserList,
1338
- {
1339
- users,
1340
- currentUsername: username,
1341
- height: Math.max(1, middleSectionHeight - tooltip.height - 2),
1342
- isPrivateChannel
1343
- }
1344
- )
1345
- ]
1346
- }
1347
- ),
1348
- tooltip.show && tooltip.tips.length > 0 && /* @__PURE__ */ jsx12(ToolTip, { tips: tooltip.tips, type: tooltip.type }),
1349
- /* @__PURE__ */ jsx12(
1350
- InputBox,
1351
- {
1352
- onSend: handleSubmit,
1353
- onTypingStart,
1354
- onTypingStop,
1355
- onInputChange: handleInputChange,
1356
- disabled: isInputDisabled
1357
- }
1358
- ),
1359
- /* @__PURE__ */ jsx12(
1360
- StatusBar,
1361
- {
1362
- connectionStatus,
1363
- error,
1364
- userCount: users.length
1365
- }
1366
- )
1367
- ] })
1368
- ] });
1369
- }
1370
-
1371
- // src/components/CreateChannelScreen.tsx
1372
- import { useState as useState6, useEffect as useEffect6 } from "react";
1373
- import { Box as Box12, Text as Text11, useInput as useInput4, useStdout as useStdout4 } from "ink";
1374
- import TextInput2 from "ink-text-input";
1375
- import { jsx as jsx13, jsxs as jsxs12 } from "react/jsx-runtime";
1376
- function CreateChannelScreen({
1377
- width,
1378
- height,
1379
- username,
1380
- connectionStatus,
1381
- onLogout,
1382
- onCreateChannel,
1383
- topPadding = 0
1384
- }) {
1385
- const { stdout } = useStdout4();
1386
- const { navigate } = useNavigation();
1387
- const [name, setName] = useState6("");
1388
- const [description, setDescription] = useState6("");
1389
- const [activeField, setActiveField] = useState6("name");
1390
- const [isSubmitting, setIsSubmitting] = useState6(false);
1391
- const [error, setError] = useState6(null);
1392
- useEffect6(() => {
1393
- if (!stdout) return;
1394
- stdout.write(`\x1B]0;Create Channel\x07`);
1395
- }, [stdout]);
1396
- useInput4((input, key) => {
1397
- if (key.escape) {
1398
- navigate("menu");
1399
- return;
1400
- }
1401
- if (key.tab && !key.shift) {
1402
- setActiveField((prev) => {
1403
- if (prev === "name") return "description";
1404
- if (prev === "description") return "submit";
1405
- return "name";
1406
- });
1407
- return;
1408
- }
1409
- if (key.tab && key.shift) {
1410
- setActiveField((prev) => {
1411
- if (prev === "submit") return "description";
1412
- if (prev === "description") return "name";
1413
- return "submit";
1414
- });
1415
- return;
1416
- }
1417
- if (key.downArrow) {
1418
- setActiveField((prev) => {
1419
- if (prev === "name") return "description";
1420
- if (prev === "description") return "submit";
1421
- return prev;
1422
- });
1423
- return;
1424
- }
1425
- if (key.upArrow) {
1426
- setActiveField((prev) => {
1427
- if (prev === "submit") return "description";
1428
- if (prev === "description") return "name";
1429
- return prev;
1430
- });
1431
- return;
1432
- }
1433
- if (key.return && activeField === "submit" && !isSubmitting) {
1434
- handleSubmit();
1435
- }
1436
- });
1437
- const handleSubmit = async () => {
1438
- if (!name.trim()) {
1439
- setError("Channel name is required");
1440
- return;
1441
- }
1442
- setError(null);
1443
- setIsSubmitting(true);
1444
- try {
1445
- await onCreateChannel(name.trim(), description.trim());
1446
- navigate("menu");
1447
- } catch (err) {
1448
- setError(err instanceof Error ? err.message : "Failed to create channel");
1449
- setIsSubmitting(false);
1450
- }
1451
- };
1452
- const headerHeight = 3;
1453
- const contentHeight = height - topPadding - headerHeight;
1454
- return /* @__PURE__ */ jsxs12(Layout, { width, height, topPadding, children: [
1455
- /* @__PURE__ */ jsx13(Layout.Header, { children: /* @__PURE__ */ jsx13(
1456
- Header,
1457
- {
1458
- username,
1459
- roomName: "Create Channel",
1460
- connectionStatus,
1461
- onLogout,
1462
- title: /* @__PURE__ */ jsx13(Text11, { bold: true, color: "cyan", children: "Create New Private Channel" }),
1463
- showStatus: false
1464
- }
1465
- ) }),
1466
- /* @__PURE__ */ jsx13(Layout.Content, { children: /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", height: contentHeight, padding: 2, children: [
1467
- /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginBottom: 1, children: [
1468
- /* @__PURE__ */ jsx13(Box12, { marginBottom: 0, children: /* @__PURE__ */ jsxs12(Text11, { bold: true, color: activeField === "name" ? "green" : "white", children: [
1469
- "Channel Name ",
1470
- activeField === "name" ? "(editing)" : ""
1471
- ] }) }),
1472
- /* @__PURE__ */ jsx13(
1473
- Box12,
1474
- {
1475
- borderStyle: "single",
1476
- borderColor: activeField === "name" ? "green" : "gray",
1477
- paddingX: 1,
1478
- children: activeField === "name" ? /* @__PURE__ */ jsx13(
1479
- TextInput2,
1480
- {
1481
- value: name,
1482
- onChange: setName,
1483
- placeholder: "Enter channel name..."
1484
- }
1485
- ) : /* @__PURE__ */ jsx13(Text11, { color: name ? "white" : "gray", children: name || "Enter channel name..." })
1486
- }
1487
- )
1488
- ] }),
1489
- /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginBottom: 1, children: [
1490
- /* @__PURE__ */ jsx13(Box12, { marginBottom: 0, children: /* @__PURE__ */ jsxs12(Text11, { bold: true, color: activeField === "description" ? "green" : "white", children: [
1491
- "Description (optional) ",
1492
- activeField === "description" ? "(editing)" : ""
1493
- ] }) }),
1494
- /* @__PURE__ */ jsx13(
1495
- Box12,
1496
- {
1497
- borderStyle: "single",
1498
- borderColor: activeField === "description" ? "green" : "gray",
1499
- paddingX: 1,
1500
- children: activeField === "description" ? /* @__PURE__ */ jsx13(
1501
- TextInput2,
1502
- {
1503
- value: description,
1504
- onChange: setDescription,
1505
- placeholder: "Enter channel description..."
1506
- }
1507
- ) : /* @__PURE__ */ jsx13(Text11, { color: description ? "white" : "gray", children: description || "Enter channel description..." })
1508
- }
1509
- )
1510
- ] }),
1511
- /* @__PURE__ */ jsx13(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs12(
1512
- Text11,
1513
- {
1514
- color: activeField === "submit" ? "green" : "white",
1515
- bold: activeField === "submit",
1516
- children: [
1517
- activeField === "submit" ? "> " : " ",
1518
- "[",
1519
- isSubmitting ? "Creating..." : "Create Channel",
1520
- "]"
1521
- ]
1522
- }
1523
- ) }),
1524
- error && /* @__PURE__ */ jsx13(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text11, { color: "red", children: error }) }),
1525
- /* @__PURE__ */ jsx13(Box12, { flexGrow: 1 }),
1526
- /* @__PURE__ */ jsxs12(
1527
- Box12,
1528
- {
1529
- flexDirection: "column",
1530
- borderStyle: "single",
1531
- borderColor: "gray",
1532
- paddingX: 1,
1533
- children: [
1534
- /* @__PURE__ */ jsxs12(Text11, { color: "gray", children: [
1535
- /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: "Tab/Down" }),
1536
- " Next field"
1537
- ] }),
1538
- /* @__PURE__ */ jsxs12(Text11, { color: "gray", children: [
1539
- /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: "Shift+Tab/Up" }),
1540
- " Previous field"
1541
- ] }),
1542
- /* @__PURE__ */ jsxs12(Text11, { color: "gray", children: [
1543
- /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: "Enter" }),
1544
- " Submit (when on button)"
1545
- ] }),
1546
- /* @__PURE__ */ jsxs12(Text11, { color: "gray", children: [
1547
- /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: "ESC" }),
1548
- " Back to menu"
1549
- ] })
1550
- ]
1551
- }
1552
- )
1553
- ] }) })
1554
- ] });
1555
- }
1556
-
1557
- // src/hooks/use-multi-channel-chat.ts
1558
- import { useState as useState7, useCallback as useCallback3, useRef as useRef3, useEffect as useEffect7 } from "react";
1559
-
1560
- // src/lib/channel-manager.ts
1561
- import { Socket as Socket2 } from "phoenix";
1562
- if (typeof globalThis.WebSocket === "undefined") {
1563
- throw new Error(
1564
- "WebSocket is not available. Load the ws polyfill before ChannelManager."
1565
- );
1566
- }
1567
- function extractTimestampFromUUIDv7(uuid) {
1568
- const hex = uuid.replace(/-/g, "").slice(0, 12);
1569
- const ms = parseInt(hex, 16);
1570
- return new Date(ms).toISOString();
1571
- }
1572
- var MAX_REALTIME_MESSAGES_PER_CHANNEL = 100;
1573
- var ChannelManager = class {
1574
- socket = null;
1575
- channelStates = /* @__PURE__ */ new Map();
1576
- callbacks;
1577
- wsUrl;
1578
- token;
1579
- connectionStatus = "disconnected";
1580
- currentActiveChannel = null;
1581
- username = null;
1582
- constructor(wsUrl, token, callbacks = {}) {
1583
- this.wsUrl = wsUrl;
1584
- this.token = token;
1585
- this.callbacks = callbacks;
1586
- }
1587
- /**
1588
- * Connect to the WebSocket and initialize the socket.
1589
- * Does not subscribe to any channels yet - use subscribeToChannels() for that.
1590
- */
1591
- async connect() {
1592
- this.setConnectionStatus("connecting");
1593
- this.socket = new Socket2(this.wsUrl, {
1594
- params: { token: this.token },
1595
- reconnectAfterMs: (tries) => {
1596
- return [1e3, 2e3, 5e3, 1e4][tries - 1] || 1e4;
1597
- }
1598
- });
1599
- return new Promise((resolve, reject) => {
1600
- if (!this.socket) {
1601
- reject(new Error("Socket not initialized"));
1602
- return;
1603
- }
1604
- this.socket.onOpen(() => {
1605
- this.setConnectionStatus("connected");
1606
- resolve();
1607
- });
1608
- this.socket.onError((error) => {
1609
- this.setConnectionStatus("error");
1610
- this.callbacks.onError?.("Connection error");
1611
- reject(error);
1612
- });
1613
- this.socket.onClose(() => {
1614
- this.setConnectionStatus("disconnected");
1615
- });
1616
- this.socket.connect();
1617
- });
1618
- }
1619
- /**
1620
- * Subscribe to multiple channels simultaneously.
1621
- * Each channel will have its own ChannelState for tracking messages, presence, etc.
1622
- */
1623
- async subscribeToChannels(channels) {
1624
- if (!this.socket) {
1625
- throw new Error("Socket not connected. Call connect() first.");
1626
- }
1627
- const subscriptionPromises = channels.map(
1628
- (channel) => this.subscribeToChannel(channel.slug)
1629
- );
1630
- const results = await Promise.allSettled(subscriptionPromises);
1631
- results.forEach((result, index) => {
1632
- if (result.status === "rejected") {
1633
- const channelSlug = channels[index].slug;
1634
- console.error(`Failed to subscribe to ${channelSlug}:`, result.reason);
1635
- this.callbacks.onError?.(`Failed to join channel: ${channelSlug}`);
1636
- }
1637
- });
1638
- }
1639
- /**
1640
- * Subscribe to a single channel and setup event handlers.
1641
- */
1642
- async subscribeToChannel(channelSlug) {
1643
- if (!this.socket) {
1644
- throw new Error("Socket not connected");
1645
- }
1646
- const channel = this.socket.channel(channelSlug, {});
1647
- const channelState = {
1648
- slug: channelSlug,
1649
- channel,
1650
- presence: {},
1651
- typingUsers: /* @__PURE__ */ new Set(),
1652
- realtimeMessages: [],
1653
- subscribers: []
1654
- };
1655
- this.setupChannelHandlers(channel, channelSlug);
1656
- return new Promise((resolve, reject) => {
1657
- channel.join().receive("ok", (resp) => {
1658
- const response = resp;
1659
- if (response.username && !this.username) {
1660
- this.username = response.username;
1661
- }
1662
- this.channelStates.set(channelSlug, channelState);
1663
- this.callbacks.onChannelJoined?.(channelSlug, response.username || "");
1664
- resolve();
1665
- }).receive("error", (error) => {
1666
- const errorMsg = `Failed to join channel: ${channelSlug}`;
1667
- this.callbacks.onError?.(errorMsg);
1668
- reject(error);
1669
- }).receive("timeout", () => {
1670
- const errorMsg = `Timeout joining channel: ${channelSlug}`;
1671
- this.callbacks.onError?.(errorMsg);
1672
- reject(new Error("timeout"));
1673
- });
1674
- });
1675
- }
1676
- /**
1677
- * Setup event handlers for a specific channel.
1678
- * Handlers route events to the correct channel state and callbacks.
1679
- */
1680
- setupChannelHandlers(channel, channelSlug) {
1681
- channel.on("new_message", (payload) => {
1682
- const msg = payload;
1683
- const message = {
1684
- ...msg,
1685
- timestamp: extractTimestampFromUUIDv7(msg.id)
1686
- };
1687
- if (channelSlug === this.currentActiveChannel) {
1688
- this.callbacks.onMessage?.(channelSlug, message);
1689
- } else {
1690
- const state = this.channelStates.get(channelSlug);
1691
- if (state) {
1692
- state.realtimeMessages.push(message);
1693
- if (state.realtimeMessages.length > MAX_REALTIME_MESSAGES_PER_CHANNEL) {
1694
- state.realtimeMessages.shift();
1695
- }
1696
- }
1697
- }
1698
- });
1699
- channel.on("presence_state", (payload) => {
1700
- const state = payload;
1701
- const channelState = this.channelStates.get(channelSlug);
1702
- if (channelState) {
1703
- channelState.presence = state;
1704
- }
1705
- if (channelSlug === this.currentActiveChannel) {
1706
- this.callbacks.onPresenceState?.(channelSlug, state);
1707
- }
1708
- });
1709
- channel.on("presence_diff", (payload) => {
1710
- const diff = payload;
1711
- const channelState = this.channelStates.get(channelSlug);
1712
- if (channelState) {
1713
- const next = { ...channelState.presence };
1714
- Object.keys(diff.leaves).forEach((username) => {
1715
- delete next[username];
1716
- });
1717
- Object.entries(diff.joins).forEach(([username, data]) => {
1718
- next[username] = data;
1719
- });
1720
- channelState.presence = next;
1721
- }
1722
- if (channelSlug === this.currentActiveChannel) {
1723
- this.callbacks.onPresenceDiff?.(channelSlug, diff);
1724
- }
1725
- });
1726
- channel.on("user_typing_start", (payload) => {
1727
- const { username } = payload;
1728
- const channelState = this.channelStates.get(channelSlug);
1729
- if (channelState) {
1730
- channelState.typingUsers.add(username);
1731
- }
1732
- if (channelSlug === this.currentActiveChannel) {
1733
- this.callbacks.onUserTyping?.(channelSlug, username, true);
1734
- }
1735
- });
1736
- channel.on("user_typing_stop", (payload) => {
1737
- const { username } = payload;
1738
- const channelState = this.channelStates.get(channelSlug);
1739
- if (channelState) {
1740
- channelState.typingUsers.delete(username);
1741
- }
1742
- if (channelSlug === this.currentActiveChannel) {
1743
- this.callbacks.onUserTyping?.(channelSlug, username, false);
1744
- }
1745
- });
1746
- channel.on("user_invited", (payload) => {
1747
- const { user_id, username, role, invited_by } = payload;
1748
- if (username === this.username) {
1749
- this.callbacks.onInvitedToChannel?.(channelSlug, invited_by);
1750
- } else {
1751
- const channelState = this.channelStates.get(channelSlug);
1752
- if (channelState) {
1753
- const exists = channelState.subscribers.some((s) => s.user_id === user_id);
1754
- if (!exists) {
1755
- channelState.subscribers.push({ user_id, username, role });
1756
- }
1757
- }
1758
- if (channelSlug === this.currentActiveChannel) {
1759
- this.callbacks.onUserInvitedToChannel?.(channelSlug, username, user_id, invited_by);
1760
- }
1761
- }
1762
- });
1763
- channel.on("user_removed", (payload) => {
1764
- const { user_id, username, removed_by } = payload;
1765
- if (username === this.username) {
1766
- channel.leave();
1767
- this.channelStates.delete(channelSlug);
1768
- this.callbacks.onRemovedFromChannel?.(channelSlug, removed_by);
1769
- } else {
1770
- const channelState = this.channelStates.get(channelSlug);
1771
- if (channelState) {
1772
- channelState.subscribers = channelState.subscribers.filter(
1773
- (s) => s.user_id !== user_id
1774
- );
1775
- }
1776
- if (channelSlug === this.currentActiveChannel) {
1777
- this.callbacks.onUserRemovedFromChannel?.(channelSlug, username, removed_by);
1778
- }
1779
- }
1780
- });
1781
- }
1782
- /**
1783
- * Set the currently active channel.
1784
- * This determines whether incoming messages are delivered immediately or buffered.
1785
- */
1786
- setActiveChannel(channelSlug) {
1787
- this.currentActiveChannel = channelSlug;
1788
- }
1789
- /**
1790
- * Fetch message history for a specific channel from the HTTP API.
1791
- */
1792
- async fetchHistory(channelSlug, limit = 50) {
1793
- const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1794
- const encodedSlug = encodeURIComponent(channelSlug);
1795
- const url = `${backendUrl}/api/messages/${encodedSlug}?limit=${limit}`;
1796
- const response = await fetch(url, {
1797
- headers: {
1798
- Authorization: `Bearer ${this.token}`
1799
- }
1800
- });
1801
- if (!response.ok) {
1802
- throw new Error(`Failed to fetch message history: ${response.status}`);
1803
- }
1804
- const data = await response.json();
1805
- return data.messages || [];
1806
- }
1807
- /**
1808
- * Fetch and store subscriber list for a private channel.
1809
- * Only applicable to private channels.
1810
- */
1811
- async fetchSubscribers(channelSlug) {
1812
- if (!channelSlug.startsWith("private_room:")) {
1813
- return [];
1814
- }
1815
- const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1816
- const encodedSlug = encodeURIComponent(channelSlug);
1817
- const url = `${backendUrl}/api/channels/${encodedSlug}/subscribers`;
1818
- const response = await fetch(url, {
1819
- headers: {
1820
- Authorization: `Bearer ${this.token}`
1821
- }
1822
- });
1823
- if (!response.ok) {
1824
- throw new Error(`Failed to fetch subscribers: ${response.status}`);
1825
- }
1826
- const data = await response.json();
1827
- const subscribers = data.subscribers || [];
1828
- const channelState = this.channelStates.get(channelSlug);
1829
- if (channelState) {
1830
- channelState.subscribers = subscribers;
1831
- }
1832
- return subscribers;
1833
- }
1834
- /**
1835
- * Get subscriber list for a specific channel.
1836
- */
1837
- getSubscribers(channelSlug) {
1838
- const channelState = this.channelStates.get(channelSlug);
1839
- return channelState?.subscribers || [];
1840
- }
1841
- /**
1842
- * Send a message to a specific channel.
1843
- */
1844
- async sendMessage(channelSlug, content) {
1845
- const channelState = this.channelStates.get(channelSlug);
1846
- if (!channelState) {
1847
- throw new Error(`Not subscribed to channel: ${channelSlug}`);
1848
- }
1849
- if (!this.socket || this.connectionStatus !== "connected") {
1850
- throw new Error("Connection lost");
1851
- }
1852
- const channel = channelState.channel;
1853
- return new Promise((resolve, reject) => {
1854
- channel.push("new_message", { content }).receive("ok", (resp) => {
1855
- const response = resp;
1856
- resolve(response);
1857
- }).receive("error", (err) => {
1858
- const error = err;
1859
- const errorMsg = error.reason || "Failed to send message";
1860
- this.callbacks.onError?.(errorMsg);
1861
- reject(new Error(errorMsg));
1862
- }).receive("timeout", () => {
1863
- const errorMsg = "Message send timeout";
1864
- this.callbacks.onError?.(errorMsg);
1865
- reject(new Error("timeout"));
1866
- });
1867
- });
1868
- }
1869
- /**
1870
- * Send a custom command event to a specific channel.
1871
- */
1872
- async sendCommand(channelSlug, eventType, data) {
1873
- const channelState = this.channelStates.get(channelSlug);
1874
- if (!channelState) {
1875
- throw new Error(`Not subscribed to channel: ${channelSlug}`);
1876
- }
1877
- if (!this.socket || this.connectionStatus !== "connected") {
1878
- throw new Error("Connection lost");
1879
- }
1880
- const channel = channelState.channel;
1881
- return new Promise((resolve, reject) => {
1882
- channel.push(eventType, data).receive("ok", (resp) => {
1883
- const response = resp;
1884
- resolve(response);
1885
- }).receive("error", (err) => {
1886
- const error = err;
1887
- const errorMsg = error.reason || "Failed to send command";
1888
- this.callbacks.onError?.(errorMsg);
1889
- reject(new Error(errorMsg));
1890
- }).receive("timeout", () => {
1891
- const errorMsg = "Command send timeout";
1892
- this.callbacks.onError?.(errorMsg);
1893
- reject(new Error("timeout"));
1894
- });
1895
- });
1896
- }
1897
- /**
1898
- * Send typing:start event to a specific channel.
1899
- */
1900
- startTyping(channelSlug) {
1901
- if (this.connectionStatus !== "connected") return;
1902
- const channelState = this.channelStates.get(channelSlug);
1903
- if (!channelState) return;
1904
- try {
1905
- channelState.channel.push("typing:start", {});
1906
- } catch {
1907
- }
1908
- }
1909
- /**
1910
- * Send typing:stop event to a specific channel.
1911
- */
1912
- stopTyping(channelSlug) {
1913
- if (this.connectionStatus !== "connected") return;
1914
- const channelState = this.channelStates.get(channelSlug);
1915
- if (!channelState) return;
1916
- try {
1917
- channelState.channel.push("typing:stop", {});
1918
- } catch {
1919
- }
1920
- }
1921
- /**
1922
- * Push an event to all subscribed channels.
1923
- * Used for user-wide state updates like current_agent.
1924
- */
1925
- pushToAllChannels(eventType, payload) {
1926
- if (this.connectionStatus !== "connected") return;
1927
- this.channelStates.forEach((state) => {
1928
- try {
1929
- state.channel.push(eventType, payload);
1930
- } catch {
1931
- }
1932
- });
1933
- }
1934
- /**
1935
- * Get presence state for a specific channel.
1936
- */
1937
- getPresence(channelSlug) {
1938
- const channelState = this.channelStates.get(channelSlug);
1939
- return channelState?.presence || {};
1940
- }
1941
- /**
1942
- * Get buffered real-time messages for a specific channel.
1943
- * These are messages that arrived while viewing other channels.
1944
- */
1945
- getRealtimeMessages(channelSlug) {
1946
- const channelState = this.channelStates.get(channelSlug);
1947
- return channelState?.realtimeMessages || [];
1948
- }
1949
- /**
1950
- * Clear buffered real-time messages for a specific channel.
1951
- * Called after merging with fetched history.
1952
- */
1953
- clearRealtimeMessages(channelSlug) {
1954
- const channelState = this.channelStates.get(channelSlug);
1955
- if (channelState) {
1956
- channelState.realtimeMessages = [];
1957
- }
1958
- }
1959
- /**
1960
- * Get typing users for a specific channel.
1961
- */
1962
- getTypingUsers(channelSlug) {
1963
- const channelState = this.channelStates.get(channelSlug);
1964
- return channelState ? Array.from(channelState.typingUsers) : [];
1965
- }
1966
- /**
1967
- * Get the current connection status.
1968
- */
1969
- getConnectionStatus() {
1970
- return this.connectionStatus;
1971
- }
1972
- /**
1973
- * Get the username (same across all channels).
1974
- */
1975
- getUsername() {
1976
- return this.username;
1977
- }
1978
- /**
1979
- * Check if connected.
1980
- */
1981
- isConnected() {
1982
- return this.connectionStatus === "connected" && !!this.socket;
1983
- }
1984
- /**
1985
- * Mark current channel as read via WebSocket.
1986
- * Sends "mark_as_read" event to update last_seen to current seq_no.
1987
- * Gracefully handles disconnected channels (returns silently during shutdown).
1988
- */
1989
- async markChannelAsRead(channelSlug) {
1990
- const channelState = this.channelStates.get(channelSlug);
1991
- if (!channelState || !channelState.channel) {
1992
- return;
1993
- }
1994
- return new Promise((resolve, reject) => {
1995
- channelState.channel.push("mark_as_read", {}).receive("ok", (response) => {
1996
- console.log(`Marked ${channelSlug} as read`, response);
1997
- resolve();
1998
- }).receive("error", (err) => {
1999
- console.error(`Failed to mark ${channelSlug} as read:`, err);
2000
- reject(err);
2001
- }).receive("timeout", () => {
2002
- console.error(`Timeout marking ${channelSlug} as read`);
2003
- reject(new Error("timeout"));
2004
- });
2005
- });
2006
- }
2007
- /**
2008
- * Mark all messages in channel as read (used when first joining).
2009
- * Gracefully handles disconnected channels (returns silently during shutdown).
2010
- */
2011
- async markAllMessagesAsRead(channelSlug) {
2012
- const channelState = this.channelStates.get(channelSlug);
2013
- if (!channelState || !channelState.channel) {
2014
- return;
2015
- }
2016
- return new Promise((resolve, reject) => {
2017
- channelState.channel.push("mark_all_read", {}).receive("ok", (response) => {
2018
- console.log(`Marked all in ${channelSlug} as read`, response);
2019
- resolve();
2020
- }).receive("error", (err) => {
2021
- console.error(`Failed to mark all as read in ${channelSlug}:`, err);
2022
- reject(err);
2023
- }).receive("timeout", () => {
2024
- console.error(`Timeout marking all as read in ${channelSlug}`);
2025
- reject(new Error("timeout"));
2026
- });
2027
- });
2028
- }
2029
- /**
2030
- * Disconnect from all channels and close the socket.
2031
- */
2032
- disconnect() {
2033
- this.channelStates.forEach((state) => {
2034
- try {
2035
- state.channel.leave();
2036
- } catch {
2037
- }
2038
- });
2039
- if (this.socket) {
2040
- this.socket.disconnect();
2041
- this.socket = null;
2042
- }
2043
- this.channelStates.clear();
2044
- this.currentActiveChannel = null;
2045
- this.username = null;
2046
- this.setConnectionStatus("disconnected");
2047
- }
2048
- /**
2049
- * Set connection status and notify callback.
2050
- */
2051
- setConnectionStatus(status) {
2052
- this.connectionStatus = status;
2053
- this.callbacks.onConnectionChange?.(status);
2054
- }
2055
- };
2056
-
2057
- // src/hooks/use-multi-channel-chat.ts
2058
- function useMultiChannelChat(token, currentChannel, onChannelListChanged) {
2059
- const [messages, setMessages] = useState7([]);
2060
- const [connectionStatus, setConnectionStatus] = useState7("disconnected");
2061
- const [username, setUsername] = useState7(null);
2062
- const [error, setError] = useState7(null);
2063
- const [typingUsers, setTypingUsers] = useState7([]);
2064
- const [presenceState, setPresenceState] = useState7({});
2065
- const [subscribers, setSubscribers] = useState7([]);
2066
- const managerRef = useRef3(null);
2067
- const prevChannelRef = useRef3(null);
2068
- const isLoadingHistory = useRef3(false);
2069
- useEffect7(() => {
2070
- if (!token) {
2071
- if (managerRef.current) {
2072
- managerRef.current.disconnect();
2073
- managerRef.current = null;
2074
- }
2075
- return;
2076
- }
2077
- if (managerRef.current) {
2078
- return;
2079
- }
2080
- const config = getConfig();
2081
- const manager = new ChannelManager(
2082
- config.wsUrl,
2083
- token,
2084
- {
2085
- onMessage: (channelSlug, message) => {
2086
- setMessages((prev) => [...prev, message]);
2087
- },
2088
- onPresenceState: (channelSlug, state) => {
2089
- setPresenceState(state);
2090
- },
2091
- onPresenceDiff: (channelSlug, diff) => {
2092
- setPresenceState((prev) => {
2093
- const next = { ...prev };
2094
- Object.keys(diff.leaves).forEach((username2) => {
2095
- delete next[username2];
2096
- });
2097
- Object.entries(diff.joins).forEach(([username2, data]) => {
2098
- next[username2] = data;
2099
- });
2100
- return next;
2101
- });
2102
- },
2103
- onUserTyping: (channelSlug, username2, typing) => {
2104
- setTypingUsers((prev) => {
2105
- if (typing) {
2106
- return prev.includes(username2) ? prev : [...prev, username2];
2107
- } else {
2108
- return prev.filter((u) => u !== username2);
2109
- }
2110
- });
2111
- },
2112
- onConnectionChange: (status) => {
2113
- setConnectionStatus(status);
2114
- if (status === "disconnected" || status === "error") {
2115
- setError(null);
2116
- }
2117
- },
2118
- onError: (err) => {
2119
- setError(err);
2120
- },
2121
- onChannelJoined: (channelSlug, joinedUsername) => {
2122
- if (!username) {
2123
- setUsername(joinedUsername);
2124
- }
2125
- },
2126
- onInvitedToChannel: (channelSlug, invitedBy) => {
2127
- if (managerRef.current) {
2128
- const authToken2 = token;
2129
- async function joinNewChannel() {
2130
- if (!authToken2 || !manager) return;
2131
- try {
2132
- const channelsResponse = await fetchChannels(config.wsUrl, authToken2);
2133
- const allChannels = [
2134
- ...channelsResponse.channels.public,
2135
- ...channelsResponse.channels.private
2136
- ];
2137
- const newChannel = allChannels.find((ch) => ch.slug === channelSlug);
2138
- if (newChannel) {
2139
- await manager.subscribeToChannels([newChannel]);
2140
- onChannelListChanged?.();
2141
- }
2142
- } catch (err) {
2143
- console.error("Failed to join new channel:", err);
2144
- }
2145
- }
2146
- joinNewChannel();
2147
- }
2148
- },
2149
- onUserInvitedToChannel: (channelSlug, invitedUsername, invitedUserId, invitedBy) => {
2150
- setSubscribers((prev) => {
2151
- const exists = prev.some((s) => s.user_id === invitedUserId);
2152
- if (!exists) {
2153
- return [...prev, { username: invitedUsername, user_id: invitedUserId, role: "member" }];
2154
- }
2155
- return prev;
2156
- });
2157
- },
2158
- onRemovedFromChannel: (channelSlug, removedBy) => {
2159
- setError(`You were removed from ${channelSlug} by ${removedBy}`);
2160
- },
2161
- onUserRemovedFromChannel: (channelSlug, removedUsername, removedBy) => {
2162
- setSubscribers((prev) => prev.filter((s) => s.username !== removedUsername));
2163
- }
2164
- }
2165
- );
2166
- managerRef.current = manager;
2167
- const authToken = token;
2168
- async function init() {
2169
- if (!authToken) {
2170
- return;
2171
- }
2172
- try {
2173
- await manager.connect();
2174
- const channelsResponse = await fetchChannels(config.wsUrl, authToken);
2175
- const allChannels = [
2176
- ...channelsResponse.channels.public,
2177
- ...channelsResponse.channels.private
2178
- ];
2179
- await manager.subscribeToChannels(allChannels);
2180
- setError(null);
2181
- } catch (err) {
2182
- setError(err instanceof Error ? err.message : "Connection failed");
2183
- console.error("Failed to initialize multi-channel chat:", err);
2184
- }
2185
- }
2186
- init();
2187
- return () => {
2188
- if (managerRef.current) {
2189
- managerRef.current.disconnect();
2190
- managerRef.current = null;
2191
- }
2192
- };
2193
- }, [token]);
2194
- useEffect7(() => {
2195
- const manager = managerRef.current;
2196
- if (!manager || !manager.isConnected() || !currentChannel) {
2197
- return;
2198
- }
2199
- if (prevChannelRef.current && prevChannelRef.current !== currentChannel) {
2200
- manager.stopTyping(prevChannelRef.current);
2201
- }
2202
- prevChannelRef.current = currentChannel;
2203
- manager.setActiveChannel(currentChannel);
2204
- async function loadHistory() {
2205
- if (isLoadingHistory.current || !manager) return;
2206
- isLoadingHistory.current = true;
2207
- try {
2208
- const history = await manager.fetchHistory(currentChannel);
2209
- if (currentChannel.startsWith("private_room:")) {
2210
- const subs = await manager.fetchSubscribers(currentChannel);
2211
- setSubscribers(subs);
2212
- } else {
2213
- setSubscribers([]);
2214
- }
2215
- const realtimeMessages = manager.getRealtimeMessages(currentChannel);
2216
- const merged = [...history, ...realtimeMessages];
2217
- const seen = /* @__PURE__ */ new Set();
2218
- const deduplicated = merged.filter((msg) => {
2219
- if (seen.has(msg.id)) return false;
2220
- seen.add(msg.id);
2221
- return true;
2222
- });
2223
- deduplicated.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
2224
- setMessages(deduplicated);
2225
- manager.clearRealtimeMessages(currentChannel);
2226
- const presence = manager.getPresence(currentChannel);
2227
- setPresenceState(presence);
2228
- const typing = manager.getTypingUsers(currentChannel);
2229
- setTypingUsers(typing);
2230
- setError(null);
2231
- } catch (err) {
2232
- setError(err instanceof Error ? err.message : "Failed to load history");
2233
- console.error("Failed to load message history:", err);
2234
- } finally {
2235
- isLoadingHistory.current = false;
2236
- }
2237
- }
2238
- loadHistory();
2239
- }, [currentChannel, connectionStatus]);
2240
- const sendMessage = useCallback3(
2241
- async (content) => {
2242
- if (!managerRef.current) {
2243
- throw new Error("Not connected");
2244
- }
2245
- await managerRef.current.sendMessage(currentChannel, content);
2246
- },
2247
- [currentChannel]
2248
- );
2249
- const startTyping = useCallback3(() => {
2250
- managerRef.current?.startTyping(currentChannel);
2251
- }, [currentChannel]);
2252
- const stopTyping = useCallback3(() => {
2253
- managerRef.current?.stopTyping(currentChannel);
2254
- }, [currentChannel]);
2255
- const connect = useCallback3(() => {
2256
- }, []);
2257
- const disconnect = useCallback3(() => {
2258
- }, []);
2259
- return {
2260
- messages,
2261
- connectionStatus,
2262
- username,
2263
- error,
2264
- sendMessage,
2265
- startTyping,
2266
- stopTyping,
2267
- typingUsers,
2268
- presenceState,
2269
- subscribers,
2270
- connect,
2271
- // No-op for backward compatibility
2272
- disconnect,
2273
- // No-op for backward compatibility
2274
- channelManager: managerRef.current
2275
- };
2276
- }
2277
-
2278
- // src/hooks/use-presence.ts
2279
- import { useMemo as useMemo5 } from "react";
2280
- function presenceToUsers(presence) {
2281
- return Object.entries(presence).map(([username, data]) => ({
2282
- username,
2283
- user_id: data.metas[0]?.user_id ?? 0,
2284
- online_at: data.metas[0]?.online_at || "",
2285
- currentAgent: data.metas[0]?.current_agent ?? null
2286
- }));
2287
- }
2288
- function mergeSubscribersWithPresence(subscribers, presence, isPrivateChannel) {
2289
- if (!isPrivateChannel) {
2290
- const onlineUsers = presenceToUsers(presence);
2291
- return onlineUsers.map((user) => ({
2292
- ...user,
2293
- isOnline: true,
2294
- currentAgent: user.currentAgent
2295
- }));
2296
- }
2297
- const onlineUsernames = new Set(Object.keys(presence));
2298
- return subscribers.map((subscriber) => {
2299
- const isOnline = onlineUsernames.has(subscriber.username);
2300
- return {
2301
- username: subscriber.username,
2302
- user_id: subscriber.user_id,
2303
- online_at: isOnline ? presence[subscriber.username].metas[0]?.online_at || "" : "",
2304
- isOnline,
2305
- role: subscriber.role,
2306
- currentAgent: isOnline ? presence[subscriber.username].metas[0]?.current_agent ?? null : null
2307
- };
2308
- });
2309
- }
2310
- function usePresence(presenceState, subscribers = [], currentChannel = "") {
2311
- const isPrivateChannel = currentChannel.startsWith("private_room:");
2312
- const users = useMemo5(
2313
- () => mergeSubscribersWithPresence(subscribers, presenceState, isPrivateChannel),
2314
- [presenceState, subscribers, isPrivateChannel]
2315
- );
2316
- return { users };
2317
- }
2318
-
2319
- // src/hooks/use-agent-detection.ts
2320
- import { useEffect as useEffect8, useRef as useRef4, useCallback as useCallback4 } from "react";
2321
- import { execSync } from "child_process";
2322
- var POLL_INTERVAL_MS = 2e3;
2323
- function isProcessRunning(processName) {
2324
- try {
2325
- execSync(`pgrep -nx ${processName}`, { stdio: "pipe" });
2326
- return true;
2327
- } catch {
2328
- return false;
2329
- }
2330
- }
2331
- function detectCurrentAgent() {
2332
- if (isProcessRunning("codex")) return "codex";
2333
- if (isProcessRunning("claude")) return "claude";
2334
- return null;
2335
- }
2336
- function useAgentDetection(channelManager, isConnected) {
2337
- const lastSentAgentRef = useRef4(void 0);
2338
- const broadcastAgentUpdate = useCallback4(
2339
- (agent) => {
2340
- if (!channelManager) return;
2341
- channelManager.pushToAllChannels("update_current_agent", {
2342
- current_agent: agent
2343
- });
2344
- },
2345
- [channelManager]
2346
- );
2347
- useEffect8(() => {
2348
- if (!channelManager || !isConnected) {
2349
- lastSentAgentRef.current = void 0;
2350
- return;
2351
- }
2352
- const initialAgent = detectCurrentAgent();
2353
- if (lastSentAgentRef.current === void 0 || lastSentAgentRef.current !== initialAgent) {
2354
- lastSentAgentRef.current = initialAgent;
2355
- broadcastAgentUpdate(initialAgent);
2356
- }
2357
- const intervalId = setInterval(() => {
2358
- const currentAgent = detectCurrentAgent();
2359
- if (currentAgent !== lastSentAgentRef.current) {
2360
- lastSentAgentRef.current = currentAgent;
2361
- broadcastAgentUpdate(currentAgent);
2362
- }
2363
- }, POLL_INTERVAL_MS);
2364
- return () => clearInterval(intervalId);
2365
- }, [channelManager, isConnected, broadcastAgentUpdate]);
2366
- }
2367
-
2368
- // src/hooks/use-channels.ts
2369
- import { useState as useState8, useCallback as useCallback5, useEffect as useEffect9 } from "react";
2370
- function useChannels(token) {
2371
- const [publicChannels, setPublicChannels] = useState8([]);
2372
- const [privateChannels, setPrivateChannels] = useState8([]);
2373
- const [unreadCounts, setUnreadCounts] = useState8({});
2374
- const [loading, setLoading] = useState8(false);
2375
- const [error, setError] = useState8(null);
2376
- const fetchData = useCallback5(async () => {
2377
- if (!token) return;
2378
- setLoading(true);
2379
- setError(null);
2380
- try {
2381
- const config = getConfig();
2382
- const [channelsData, unreadData] = await Promise.all([
2383
- fetchChannels(config.wsUrl, token),
2384
- fetchUnreadCounts(config.wsUrl, token)
2385
- ]);
2386
- setPublicChannels(channelsData.channels.public);
2387
- setPrivateChannels(channelsData.channels.private);
2388
- setUnreadCounts(unreadData);
2389
- } catch (err) {
2390
- setError(err instanceof Error ? err.message : "Failed to fetch data");
2391
- } finally {
2392
- setLoading(false);
2393
- }
2394
- }, [token]);
2395
- const refetchUnreadCounts = useCallback5(async () => {
2396
- if (!token) return;
2397
- try {
2398
- const config = getConfig();
2399
- const unreadData = await fetchUnreadCounts(config.wsUrl, token);
2400
- setUnreadCounts(unreadData);
2401
- } catch (err) {
2402
- console.error("Failed to refetch unread counts:", err);
2403
- }
2404
- }, [token]);
2405
- useEffect9(() => {
2406
- if (token) {
2407
- fetchData();
2408
- }
2409
- }, [token, fetchData]);
2410
- return {
2411
- publicChannels,
2412
- privateChannels,
2413
- unreadCounts,
2414
- loading,
2415
- error,
2416
- refetch: fetchData,
2417
- refetchUnreadCounts
2418
- };
2419
- }
2420
-
2421
- // src/components/App.tsx
2422
- import { jsx as jsx14 } from "react/jsx-runtime";
2423
- function App() {
2424
- return /* @__PURE__ */ jsx14(Router, { initialRoute: "menu", children: /* @__PURE__ */ jsx14(AppContent, {}) });
2425
- }
2426
- function AppContent() {
2427
- const { exit } = useApp();
2428
- const { stdout } = useStdout5();
2429
- const { route, navigate } = useNavigation();
2430
- const isWarp = process.env.TERM_PROGRAM === "WarpTerminal";
2431
- const topPadding = isWarp ? 1 : 0;
2432
- const [authState, setAuthState] = useState9("unauthenticated");
2433
- const [authStatus, setAuthStatus] = useState9("");
2434
- const [token, setToken] = useState9(null);
2435
- const [terminalSize, setTerminalSize] = useState9({
2436
- rows: stdout?.rows || 24,
2437
- columns: stdout?.columns || 80
2438
- });
2439
- const [scrollOffset, setScrollOffset] = useState9(0);
2440
- const [isScrollDetached, setIsScrollDetached] = useState9(false);
2441
- const [showUserList, setShowUserList] = useState9(true);
2442
- const [currentChannel, setCurrentChannel] = useState9("chat_room:global");
2443
- const prevAuthStateRef = useRef5(null);
2444
- useEffect10(() => {
2445
- if (!stdout) return;
2446
- const handleResize = () => {
2447
- setTerminalSize({
2448
- rows: stdout.rows || 24,
2449
- columns: stdout.columns || 80
2450
- });
2451
- };
2452
- stdout.on("resize", handleResize);
2453
- return () => {
2454
- stdout.off("resize", handleResize);
2455
- };
2456
- }, [stdout]);
2457
- useEffect10(() => {
2458
- if (!stdout) return;
2459
- if (prevAuthStateRef.current === "authenticated" && authState !== "authenticated") {
2460
- stdout.write("\x1B[2J\x1B[0f");
2461
- }
2462
- prevAuthStateRef.current = authState;
2463
- }, [authState, stdout]);
2464
- useEffect10(() => {
2465
- async function checkAuth() {
2466
- const authenticated = await isAuthenticated();
2467
- if (authenticated) {
2468
- const stored = await getCurrentToken();
2469
- if (stored) {
2470
- setToken(stored.token);
2471
- setAuthState("authenticated");
2472
- }
2473
- }
2474
- }
2475
- checkAuth();
2476
- }, []);
2477
- const { publicChannels, privateChannels, unreadCounts, refetchUnreadCounts, refetch: refetchChannels } = useChannels(token);
2478
- const {
2479
- messages,
2480
- connectionStatus,
2481
- username,
2482
- error,
2483
- sendMessage,
2484
- startTyping,
2485
- stopTyping,
2486
- typingUsers,
2487
- presenceState,
2488
- subscribers,
2489
- connect,
2490
- disconnect,
2491
- channelManager
2492
- } = useMultiChannelChat(token, currentChannel, refetchChannels);
2493
- const { users } = usePresence(presenceState, subscribers, currentChannel);
2494
- useAgentDetection(channelManager, connectionStatus === "connected");
2495
- const sendCommand = useCallback6(
2496
- async (eventType, data) => {
2497
- if (!channelManager) {
2498
- throw new Error("Not connected");
2499
- }
2500
- await channelManager.sendCommand(currentChannel, eventType, data);
2501
- },
2502
- [currentChannel, channelManager]
2503
- );
2504
- const allChannels = [...publicChannels, ...privateChannels];
2505
- const currentChannelDetails = allChannels.find((ch) => ch.slug === currentChannel);
2506
- const isPrivateChannel = currentChannel.startsWith("private_room:");
2507
- const prevChannelForMarkAsReadRef = useRef5(null);
2508
- const markedAsReadOnEntryRef = useRef5(/* @__PURE__ */ new Set());
2509
- useEffect10(() => {
2510
- const markChannelAsRead = async (channelSlug, isEntry) => {
2511
- if (!channelManager) {
2512
- return;
2513
- }
2514
- try {
2515
- if (isEntry) {
2516
- if (!markedAsReadOnEntryRef.current.has(channelSlug)) {
2517
- await channelManager.markAllMessagesAsRead(channelSlug);
2518
- markedAsReadOnEntryRef.current.add(channelSlug);
2519
- } else {
2520
- await channelManager.markChannelAsRead(channelSlug);
2521
- }
2522
- } else {
2523
- await channelManager.markChannelAsRead(channelSlug);
2524
- }
2525
- await refetchUnreadCounts();
2526
- } catch (err) {
2527
- console.error(`Failed to mark ${channelSlug} as read:`, err);
2528
- }
2529
- };
2530
- if (currentChannel !== prevChannelForMarkAsReadRef.current) {
2531
- if (prevChannelForMarkAsReadRef.current) {
2532
- markChannelAsRead(prevChannelForMarkAsReadRef.current, false);
2533
- }
2534
- if (currentChannel) {
2535
- markChannelAsRead(currentChannel, true);
2536
- }
2537
- prevChannelForMarkAsReadRef.current = currentChannel;
2538
- }
2539
- }, [currentChannel, channelManager, refetchUnreadCounts]);
2540
- useEffect10(() => {
2541
- return () => {
2542
- if (currentChannel && channelManager) {
2543
- const markOnUnmount = async () => {
2544
- try {
2545
- await channelManager.markChannelAsRead(currentChannel);
2546
- } catch (err) {
2547
- console.error("Failed to mark as read on unmount:", err);
2548
- }
2549
- };
2550
- markOnUnmount();
2551
- }
2552
- };
2553
- }, [currentChannel, channelManager]);
2554
- useEffect10(() => {
2555
- if (route === "menu") {
2556
- refetchUnreadCounts();
2557
- }
2558
- }, [route, refetchUnreadCounts]);
2559
- const handleLogin = useCallback6(async () => {
2560
- setAuthState("authenticating");
2561
- setAuthStatus("Starting login...");
2562
- const result = await login((status) => setAuthStatus(status));
2563
- if (result.success) {
2564
- const stored = await getCurrentToken();
2565
- if (stored) {
2566
- setToken(stored.token);
2567
- setAuthState("authenticated");
2568
- setAuthStatus("");
2569
- }
2570
- } else {
2571
- setAuthState("unauthenticated");
2572
- setAuthStatus(result.error || "Login failed");
2573
- }
2574
- }, []);
2575
- const handleLogout = useCallback6(async () => {
2576
- if (currentChannel && channelManager) {
2577
- try {
2578
- await channelManager.markChannelAsRead(currentChannel);
2579
- } catch (err) {
2580
- console.error("Failed to mark as read on logout:", err);
2581
- }
2582
- }
2583
- disconnect();
2584
- setToken(null);
2585
- setAuthState("unauthenticated");
2586
- setAuthStatus("");
2587
- try {
2588
- await logout();
2589
- } catch {
2590
- setAuthStatus("Logged out locally; failed to clear credentials.");
2591
- }
2592
- }, [disconnect, currentChannel, channelManager]);
2593
- const handleCreateChannel = useCallback6(async (name, description) => {
2594
- if (!token) {
2595
- throw new Error("Not authenticated");
2596
- }
2597
- const config = getConfig();
2598
- await createChannel(config.wsUrl, token, name, description || void 0);
2599
- await refetchChannels();
2600
- }, [token, refetchChannels]);
2601
- const headerHeight = 3;
2602
- const inputBoxHeight = 4;
2603
- const statusBarHeight = 1;
2604
- const middleSectionHeight = Math.max(
2605
- 5,
2606
- terminalSize.rows - topPadding - headerHeight - inputBoxHeight - statusBarHeight
2607
- );
2608
- const linesPerMessage = 2;
2609
- const maxVisibleMessages = Math.floor(middleSectionHeight / linesPerMessage);
2610
- useInput5((input, key) => {
2611
- if (input === "c" && key.ctrl) {
2612
- const handleExit = async () => {
2613
- if (currentChannel && channelManager) {
2614
- try {
2615
- await channelManager.markChannelAsRead(currentChannel);
2616
- } catch (err) {
2617
- console.error("Failed to mark as read on exit:", err);
2618
- }
2619
- }
2620
- disconnect();
2621
- exit();
2622
- };
2623
- handleExit();
2624
- }
2625
- if (input === "o" && key.ctrl && authState === "authenticated") {
2626
- handleLogout();
2627
- }
2628
- if (input === "e" && key.ctrl && authState === "authenticated") {
2629
- setShowUserList((prev) => !prev);
2630
- }
2631
- if (input === "q" && key.ctrl && authState === "authenticated" && route === "chat") {
2632
- navigate("menu");
2633
- }
2634
- if (authState === "authenticated") {
2635
- const maxOffset = Math.max(0, messages.length - maxVisibleMessages);
2636
- if (key.upArrow) {
2637
- setScrollOffset((prev) => {
2638
- const newOffset = Math.min(prev + 1, maxOffset);
2639
- if (newOffset > 0) {
2640
- setIsScrollDetached(true);
2641
- }
2642
- return newOffset;
2643
- });
2644
- }
2645
- if (key.downArrow) {
2646
- setScrollOffset((prev) => {
2647
- const newOffset = Math.max(prev - 1, 0);
2648
- if (newOffset === 0) {
2649
- setIsScrollDetached(false);
2650
- }
2651
- return newOffset;
2652
- });
2653
- }
2654
- }
2655
- });
2656
- if (authState !== "authenticated") {
2657
- return /* @__PURE__ */ jsx14(
2658
- Box13,
2659
- {
2660
- flexDirection: "column",
2661
- width: terminalSize.columns,
2662
- height: terminalSize.rows,
2663
- overflow: "hidden",
2664
- children: /* @__PURE__ */ jsx14(
2665
- LoginScreen,
2666
- {
2667
- onLogin: handleLogin,
2668
- status: authStatus,
2669
- isLoading: authState === "authenticating"
2670
- }
2671
- )
2672
- }
2673
- );
2674
- }
2675
- if (route === "menu") {
2676
- return /* @__PURE__ */ jsx14(
2677
- Box13,
2678
- {
2679
- flexDirection: "column",
2680
- width: terminalSize.columns,
2681
- height: terminalSize.rows,
2682
- overflow: "hidden",
2683
- children: /* @__PURE__ */ jsx14(
2684
- Menu,
2685
- {
2686
- width: terminalSize.columns,
2687
- height: terminalSize.rows,
2688
- currentChannel,
2689
- onChannelSelect: setCurrentChannel,
2690
- username,
2691
- connectionStatus,
2692
- onLogout: handleLogout,
2693
- topPadding,
2694
- publicChannels,
2695
- privateChannels,
2696
- unreadCounts
2697
- }
2698
- )
2699
- }
2700
- );
2701
- }
2702
- if (route === "create-channel") {
2703
- return /* @__PURE__ */ jsx14(
2704
- Box13,
2705
- {
2706
- flexDirection: "column",
2707
- width: terminalSize.columns,
2708
- height: terminalSize.rows,
2709
- overflow: "hidden",
2710
- children: /* @__PURE__ */ jsx14(
2711
- CreateChannelScreen,
2712
- {
2713
- width: terminalSize.columns,
2714
- height: terminalSize.rows,
2715
- username,
2716
- connectionStatus,
2717
- onLogout: handleLogout,
2718
- onCreateChannel: handleCreateChannel,
2719
- topPadding
2720
- }
2721
- )
2722
- }
2723
- );
2724
- }
2725
- return /* @__PURE__ */ jsx14(
2726
- ChatView,
2727
- {
2728
- terminalSize,
2729
- currentChannel,
2730
- channelName: currentChannelDetails?.name,
2731
- channelDescription: currentChannelDetails?.description || void 0,
2732
- connectionStatus,
2733
- username,
2734
- onLogout: handleLogout,
2735
- messages,
2736
- typingUsers,
2737
- middleSectionHeight,
2738
- scrollOffset,
2739
- isDetached: isScrollDetached,
2740
- showUserList,
2741
- users,
2742
- subscribers,
2743
- isPrivateChannel,
2744
- topPadding,
2745
- onSend: sendMessage,
2746
- onTypingStart: startTyping,
2747
- onTypingStop: stopTyping,
2748
- onCommandSend: sendCommand,
2749
- error,
2750
- token
2751
- }
2752
- );
2753
- }
2754
- export {
2755
- App
2756
- };