clay-server 2.38.0-beta.3 → 2.38.0-beta.5

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,35 +1,108 @@
1
1
  /* ==========================================================================
2
- Home Chat (Clay) — phablet-style mini chat embedded in the home hub
2
+ Clay FAB + popover — phablet-style chat reachable from anywhere
3
3
  ==========================================================================
4
- The home hub is a flex row: this pane sits on the left, the widget
5
- column flows to the right. The pane wraps a phone-shaped chat "frame"
6
- (rounded card with shadow) so it reads as a discrete surface, not a
7
- restyle of the main chat. */
8
-
9
- .home-chat-pane {
10
- flex: 0 0 auto;
11
- width: 50%;
12
- min-width: 360px;
13
- max-width: 720px;
4
+ The FAB (#clay-fab) sits at the bottom-right corner over all page
5
+ content. Clicking it toggles #clay-popover, which contains the
6
+ .home-chat-frame (rounded card with header / messages / input).
7
+ Inspired by Vercel's persistent toolbar pattern. */
8
+
9
+ /* --- FAB ---
10
+ Draggable. Default position is bottom-right; user position is
11
+ restored from localStorage. Inline `top`/`left` set by JS override
12
+ the default `right`/`bottom`. */
13
+
14
+ .clay-fab {
15
+ position: fixed;
16
+ right: 18px;
17
+ bottom: 18px;
18
+ width: 44px;
19
+ height: 44px;
20
+ border-radius: 50%;
21
+ border: none;
22
+ background: var(--bg, #fff);
23
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16), 0 2px 4px rgba(0, 0, 0, 0.08);
24
+ cursor: grab;
25
+ z-index: 9000;
14
26
  display: flex;
15
- align-items: stretch;
27
+ align-items: center;
16
28
  justify-content: center;
17
- padding: 32px 16px 24px;
18
- background: var(--bg);
19
- border-right: 1px solid var(--border);
29
+ padding: 0;
30
+ transition: box-shadow 0.18s, opacity 0.18s, transform 0.18s cubic-bezier(.2,.7,.3,1.3);
31
+ user-select: none;
32
+ touch-action: none;
33
+ }
34
+ .clay-fab:hover {
35
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2), 0 3px 6px rgba(0, 0, 0, 0.10);
36
+ }
37
+ .clay-fab.dragging {
38
+ cursor: grabbing;
39
+ transition: none;
40
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26), 0 4px 8px rgba(0, 0, 0, 0.14);
41
+ }
42
+ .clay-fab.open {
43
+ /* Hide while popover is open — the popover X button is the close path */
44
+ transform: scale(0.6);
45
+ opacity: 0;
46
+ pointer-events: none;
47
+ }
48
+
49
+ .clay-fab-icon {
50
+ width: 30px;
51
+ height: 30px;
52
+ border-radius: 50%;
53
+ object-fit: cover;
54
+ pointer-events: none;
20
55
  }
21
56
 
22
- .home-chat-frame {
57
+ /* Subtle pulse ring to draw the eye on first paint */
58
+ .clay-fab-pulse {
59
+ position: absolute;
60
+ inset: -2px;
61
+ border-radius: 50%;
62
+ border: 2px solid var(--accent);
63
+ opacity: 0;
64
+ pointer-events: none;
65
+ animation: clay-fab-pulse 2.4s ease-out 1s 2;
66
+ }
67
+ @keyframes clay-fab-pulse {
68
+ 0% { opacity: 0; transform: scale(1); }
69
+ 20% { opacity: 0.55; }
70
+ 100% { opacity: 0; transform: scale(1.6); }
71
+ }
72
+
73
+ /* --- Popover --- */
74
+
75
+ .clay-popover {
76
+ position: fixed;
77
+ right: 18px;
78
+ bottom: 18px;
79
+ width: 320px;
80
+ height: 480px;
81
+ max-width: calc(100vw - 32px);
82
+ max-height: calc(100vh - 40px);
83
+ z-index: 9001;
84
+ display: flex;
85
+ transform-origin: bottom right;
86
+ animation: clay-popover-in 0.18s cubic-bezier(.2,.7,.3,1.05);
87
+ }
88
+ .clay-popover.hidden {
89
+ display: none;
90
+ }
91
+ @keyframes clay-popover-in {
92
+ from { transform: translateY(8px) scale(0.96); opacity: 0; }
93
+ to { transform: translateY(0) scale(1); opacity: 1; }
94
+ }
95
+
96
+ .clay-popover .home-chat-frame {
23
97
  width: 100%;
24
- max-width: 480px;
98
+ height: 100%;
25
99
  display: flex;
26
100
  flex-direction: column;
27
101
  background: var(--bg-alt, var(--bg));
28
102
  border: 1px solid var(--border);
29
- border-radius: 24px;
30
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.10), 0 2px 6px rgba(0, 0, 0, 0.06);
103
+ border-radius: 18px;
104
+ box-shadow: 0 14px 40px rgba(0, 0, 0, 0.22), 0 4px 10px rgba(0, 0, 0, 0.10);
31
105
  overflow: hidden;
32
- height: 100%;
33
106
  }
34
107
 
35
108
  /* --- Header --- */
@@ -37,15 +110,15 @@
37
110
  .home-chat-header {
38
111
  display: flex;
39
112
  align-items: center;
40
- gap: 12px;
41
- padding: 14px 16px;
113
+ gap: 10px;
114
+ padding: 12px 12px 12px 14px;
42
115
  border-bottom: 1px solid var(--border);
43
116
  background: var(--bg);
44
117
  }
45
118
 
46
119
  .home-chat-avatar-wrap {
47
- width: 38px;
48
- height: 38px;
120
+ width: 34px;
121
+ height: 34px;
49
122
  border-radius: 50%;
50
123
  overflow: hidden;
51
124
  flex-shrink: 0;
@@ -68,14 +141,14 @@
68
141
  }
69
142
 
70
143
  .home-chat-title {
71
- font-size: 15px;
144
+ font-size: 14px;
72
145
  font-weight: 600;
73
146
  color: var(--text);
74
147
  line-height: 1.2;
75
148
  }
76
149
 
77
150
  .home-chat-subtitle {
78
- font-size: 12px;
151
+ font-size: 11px;
79
152
  color: var(--text-muted, var(--text-dimmer));
80
153
  line-height: 1.2;
81
154
  margin-top: 2px;
@@ -87,8 +160,8 @@
87
160
  .home-chat-icon-btn {
88
161
  background: none;
89
162
  border: none;
90
- width: 32px;
91
- height: 32px;
163
+ width: 30px;
164
+ height: 30px;
92
165
  border-radius: 8px;
93
166
  display: flex;
94
167
  align-items: center;
@@ -102,8 +175,8 @@
102
175
  color: var(--text);
103
176
  }
104
177
  .home-chat-icon-btn .lucide {
105
- width: 18px;
106
- height: 18px;
178
+ width: 16px;
179
+ height: 16px;
107
180
  }
108
181
 
109
182
  /* --- Messages list --- */
@@ -111,19 +184,19 @@
111
184
  .home-chat-messages {
112
185
  flex: 1;
113
186
  overflow-y: auto;
114
- padding: 16px 14px;
187
+ padding: 14px 12px;
115
188
  display: flex;
116
189
  flex-direction: column;
117
- gap: 10px;
190
+ gap: 8px;
118
191
  background: var(--input-bg, var(--bg));
119
192
  scroll-behavior: smooth;
120
193
  }
121
194
 
122
195
  .home-chat-bubble {
123
196
  max-width: 88%;
124
- padding: 10px 14px;
125
- border-radius: 18px;
126
- font-size: 14px;
197
+ padding: 9px 12px;
198
+ border-radius: 16px;
199
+ font-size: 13px;
127
200
  line-height: 1.5;
128
201
  word-wrap: break-word;
129
202
  white-space: pre-wrap;
@@ -147,15 +220,15 @@
147
220
 
148
221
  .home-chat-bubble-system {
149
222
  align-self: center;
150
- font-size: 12px;
223
+ font-size: 11px;
151
224
  color: var(--text-muted, var(--text-dimmer));
152
225
  background: transparent;
153
226
  padding: 4px 10px;
154
227
  font-style: italic;
228
+ text-align: center;
229
+ max-width: 100%;
155
230
  }
156
231
 
157
- /* Inline session reference: [project/sess_id — 2026-04-22]. Rendered as
158
- a subtle chip when the home-chat renderer detects the pattern. */
159
232
  .home-chat-ref {
160
233
  display: inline-block;
161
234
  background: rgba(124, 58, 237, 0.08);
@@ -163,7 +236,7 @@
163
236
  padding: 1px 8px;
164
237
  border-radius: 10px;
165
238
  font-family: "Roboto Mono", "Courier New", monospace;
166
- font-size: 12px;
239
+ font-size: 11px;
167
240
  cursor: pointer;
168
241
  margin: 0 2px;
169
242
  border: 1px solid rgba(124, 58, 237, 0.18);
@@ -177,14 +250,14 @@
177
250
  .home-chat-typing {
178
251
  display: flex;
179
252
  gap: 4px;
180
- padding: 6px 18px 0;
253
+ padding: 4px 16px 0;
181
254
  align-items: center;
182
255
  }
183
256
  .home-chat-typing.hidden { display: none; }
184
257
 
185
258
  .home-chat-typing-dot {
186
- width: 6px;
187
- height: 6px;
259
+ width: 5px;
260
+ height: 5px;
188
261
  border-radius: 50%;
189
262
  background: var(--text-dimmer);
190
263
  animation: home-chat-bounce 1.2s infinite ease-in-out;
@@ -204,7 +277,7 @@
204
277
  display: flex;
205
278
  align-items: flex-end;
206
279
  gap: 8px;
207
- padding: 10px 12px 12px;
280
+ padding: 10px 10px 12px;
208
281
  border-top: 1px solid var(--border);
209
282
  background: var(--bg);
210
283
  }
@@ -212,15 +285,15 @@
212
285
  .home-chat-input {
213
286
  flex: 1;
214
287
  border: 1px solid var(--border);
215
- border-radius: 18px;
216
- padding: 10px 14px;
288
+ border-radius: 16px;
289
+ padding: 9px 12px;
217
290
  background: var(--input-bg, var(--bg));
218
291
  color: var(--text);
219
- font-size: 14px;
292
+ font-size: 13px;
220
293
  line-height: 1.5;
221
294
  font-family: inherit;
222
295
  resize: none;
223
- max-height: 140px;
296
+ max-height: 120px;
224
297
  outline: none;
225
298
  transition: border-color 0.15s, box-shadow 0.15s;
226
299
  }
@@ -231,8 +304,8 @@
231
304
 
232
305
  .home-chat-send-btn {
233
306
  flex-shrink: 0;
234
- width: 38px;
235
- height: 38px;
307
+ width: 34px;
308
+ height: 34px;
236
309
  border-radius: 50%;
237
310
  border: none;
238
311
  background: var(--accent);
@@ -247,47 +320,61 @@
247
320
  opacity: 0.4;
248
321
  cursor: default;
249
322
  }
250
- .home-chat-send-btn:not(:disabled):hover {
251
- opacity: 0.9;
252
- }
253
- .home-chat-send-btn:not(:disabled):active {
254
- transform: scale(0.95);
255
- }
323
+ .home-chat-send-btn:not(:disabled):hover { opacity: 0.9; }
324
+ .home-chat-send-btn:not(:disabled):active { transform: scale(0.95); }
256
325
  .home-chat-send-btn .lucide {
257
- width: 18px;
258
- height: 18px;
326
+ width: 16px;
327
+ height: 16px;
259
328
  }
260
329
 
261
- /* --- Right widget pane --- */
330
+ /* --- Mobile / narrow ---
331
+ On phones the popover fills the screen edge-to-edge with a small
332
+ inset, and the FAB shrinks slightly. */
262
333
 
263
- .home-hub-inner {
264
- flex: 1 1 auto;
265
- overflow-y: auto;
266
- padding: 48px 24px 40px;
267
- display: flex;
268
- flex-direction: column;
269
- align-items: center;
270
- min-width: 0;
334
+ @media (max-width: 768px) {
335
+ /* Lift the FAB above the mobile tab bar (56px + safe-bottom).
336
+ Only applies when JS hasn't placed the FAB at a custom position
337
+ (custom positions use inline top/left and override these defaults). */
338
+ .clay-fab:not(.user-positioned) {
339
+ right: 14px;
340
+ bottom: calc(56px + var(--safe-bottom, 0px) + 14px);
341
+ }
342
+ .clay-fab {
343
+ width: 44px;
344
+ height: 44px;
345
+ }
346
+ .clay-fab-icon {
347
+ width: 30px;
348
+ height: 30px;
349
+ }
271
350
  }
272
351
 
273
- /* --- Mobile / narrow: stack the chat and widgets --- */
274
-
275
- @media (max-width: 900px) {
276
- #home-hub {
277
- flex-direction: column;
278
- }
279
- .home-chat-pane {
352
+ @media (max-width: 600px) {
353
+ .clay-popover {
354
+ right: 8px;
355
+ bottom: calc(56px + var(--safe-bottom, 0px) + 8px);
356
+ left: 8px;
280
357
  width: auto;
281
- max-width: none;
282
- min-width: 0;
283
- border-right: none;
284
- border-bottom: 1px solid var(--border);
285
- flex: 0 0 auto;
286
- height: 60vh;
287
- padding: 16px 12px;
358
+ height: 70vh;
359
+ max-height: calc(100vh - 80px - var(--safe-bottom, 0px));
360
+ }
361
+ .clay-popover .home-chat-frame {
362
+ border-radius: 16px;
288
363
  }
289
- .home-hub-inner {
290
- flex: 1 1 auto;
291
- padding: 24px 16px;
364
+ }
365
+
366
+ /* When the mobile tab bar is hidden by the keyboard, drop the FAB back
367
+ down so it doesn't float in dead space. */
368
+ @media (max-width: 768px) {
369
+ #mobile-tab-bar.keyboard-hidden ~ .clay-fab,
370
+ body:has(#mobile-tab-bar.keyboard-hidden) .clay-fab {
371
+ bottom: 14px;
292
372
  }
293
373
  }
374
+
375
+ /* Hide FAB while user is on auth/setup pages or any modal-heavy screens.
376
+ Add the class .clay-fab-suppressed on <body> when needed. */
377
+ body.clay-fab-suppressed .clay-fab,
378
+ body.clay-fab-suppressed .clay-popover {
379
+ display: none !important;
380
+ }
@@ -3,18 +3,18 @@
3
3
  ========================================================================== */
4
4
 
5
5
  /* Hub covers #main-area (sidebar + chat), sits inside it with absolute.
6
- Internally it splits into a Clay chat panel on the left and the
7
- existing widget column on the right. See home-chat.css for the chat
8
- panel styling. */
6
+ Pure widget surface Clay chat is reachable via the global FAB
7
+ instead of being embedded here. */
9
8
  #home-hub {
10
9
  position: absolute;
11
10
  inset: 0;
12
11
  display: flex;
13
- flex-direction: row;
14
- align-items: stretch;
15
- overflow: hidden;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ overflow-y: auto;
16
15
  background: var(--bg);
17
16
  z-index: 200;
17
+ padding: 48px 24px 40px;
18
18
  border-top-left-radius: 8px;
19
19
  animation: hubFadeIn 0.35s ease;
20
20
  }
@@ -115,39 +115,6 @@
115
115
  <!-- === Main Area (sidebar + resize-handle + main-column) === -->
116
116
  <div id="main-area">
117
117
  <div id="home-hub" class="hidden">
118
- <!-- Clay chat panel: phablet-style, self-contained. Talks to the
119
- user's Clay mate via dedicated WS messages (home_clay_*) and
120
- does not interfere with the active project session. -->
121
- <div id="home-chat-pane" class="home-chat-pane">
122
- <div class="home-chat-frame">
123
- <div class="home-chat-header">
124
- <div class="home-chat-avatar-wrap">
125
- <img class="home-chat-avatar" src="/icon-banded-76.png" alt="Clay">
126
- </div>
127
- <div class="home-chat-title-block">
128
- <div class="home-chat-title">Clay</div>
129
- <div class="home-chat-subtitle">Your workspace memory</div>
130
- </div>
131
- <button id="home-chat-new-btn" class="home-chat-icon-btn" title="Start a new conversation" aria-label="New conversation">
132
- <i data-lucide="plus"></i>
133
- </button>
134
- </div>
135
- <div id="home-chat-messages" class="home-chat-messages">
136
- <!-- messages rendered by home-chat.js -->
137
- </div>
138
- <div class="home-chat-typing hidden" id="home-chat-typing">
139
- <span class="home-chat-typing-dot"></span>
140
- <span class="home-chat-typing-dot"></span>
141
- <span class="home-chat-typing-dot"></span>
142
- </div>
143
- <div class="home-chat-input-row">
144
- <textarea id="home-chat-input" class="home-chat-input" rows="1" placeholder="Ask Clay…" autocomplete="off"></textarea>
145
- <button id="home-chat-send-btn" class="home-chat-send-btn" disabled aria-label="Send">
146
- <i data-lucide="arrow-up"></i>
147
- </button>
148
- </div>
149
- </div>
150
- </div>
151
118
  <button id="home-hub-close" class="home-hub-close-btn hidden">
152
119
  <i data-lucide="x"></i>
153
120
  <span>ESC</span>
@@ -2326,5 +2293,49 @@
2326
2293
  </div>
2327
2294
  </div>
2328
2295
  </div>
2296
+
2297
+ <!-- === Clay FAB + Popover ===
2298
+ Persistent floating-action button (bottom-right) that opens a
2299
+ phablet-style chat with Clay (the host agent). The popover overlays
2300
+ the page content and does NOT interfere with the active project
2301
+ session — talks via dedicated home_clay_* WS messages. -->
2302
+ <button id="clay-fab" class="clay-fab" type="button" aria-label="Open Clay" title="Ask Clay">
2303
+ <img class="clay-fab-icon" src="/icon-banded-76.png" alt="Clay">
2304
+ <span class="clay-fab-pulse"></span>
2305
+ </button>
2306
+
2307
+ <div id="clay-popover" class="clay-popover hidden" role="dialog" aria-modal="false" aria-labelledby="home-chat-title-text">
2308
+ <div class="home-chat-frame">
2309
+ <div class="home-chat-header">
2310
+ <div class="home-chat-avatar-wrap">
2311
+ <img class="home-chat-avatar" src="/icon-banded-76.png" alt="Clay">
2312
+ </div>
2313
+ <div class="home-chat-title-block">
2314
+ <div class="home-chat-title" id="home-chat-title-text">Clay</div>
2315
+ <div class="home-chat-subtitle">Your workspace memory</div>
2316
+ </div>
2317
+ <button id="home-chat-new-btn" class="home-chat-icon-btn" title="Start a new conversation" aria-label="New conversation">
2318
+ <i data-lucide="plus"></i>
2319
+ </button>
2320
+ <button id="home-chat-close-btn" class="home-chat-icon-btn" title="Close" aria-label="Close">
2321
+ <i data-lucide="x"></i>
2322
+ </button>
2323
+ </div>
2324
+ <div id="home-chat-messages" class="home-chat-messages">
2325
+ <!-- messages rendered by home-chat.js -->
2326
+ </div>
2327
+ <div class="home-chat-typing hidden" id="home-chat-typing">
2328
+ <span class="home-chat-typing-dot"></span>
2329
+ <span class="home-chat-typing-dot"></span>
2330
+ <span class="home-chat-typing-dot"></span>
2331
+ </div>
2332
+ <div class="home-chat-input-row">
2333
+ <textarea id="home-chat-input" class="home-chat-input" rows="1" placeholder="Ask Clay…" autocomplete="off"></textarea>
2334
+ <button id="home-chat-send-btn" class="home-chat-send-btn" disabled aria-label="Send">
2335
+ <i data-lucide="arrow-up"></i>
2336
+ </button>
2337
+ </div>
2338
+ </div>
2339
+ </div>
2329
2340
  </body>
2330
2341
  </html>
@@ -574,18 +574,11 @@ function renderHomeHubMates() {
574
574
  }
575
575
 
576
576
  export function showHomeHub() {
577
- // Home hub hosts its own Clay chat panel on the left (see home-chat.js)
578
- // and the existing widgets on the right. The chat is a self-contained
579
- // surface with its own renderer and its own WS protocol — it does NOT
580
- // hijack the user's main project session. Any active DM stays open
581
- // underneath; we just exit it visually so the hub layer is clean.
577
+ // Home hub is a pure widget surface. Clay chat is reachable from
578
+ // anywhere via the persistent FAB (#clay-fab), not embedded here.
582
579
  if (store.get('dmMode')) exitDmMode();
583
580
  homeHubVisible = true;
584
581
  homeHub.classList.remove("hidden");
585
- // Mount/refresh the in-hub Clay chat panel.
586
- try {
587
- if (typeof window.__initHomeChat === "function") window.__initHomeChat();
588
- } catch (e) {}
589
582
  // Show close button only if there's a project to return to
590
583
  if (hubCloseBtn) {
591
584
  if (store.get('currentSlug')) hubCloseBtn.classList.remove("hidden");
@@ -1,46 +1,72 @@
1
- // Home Chat (Clay) — phablet-style chat embedded in the home hub.
2
- // Self-contained: own DOM, own renderer, own WS protocol.
3
- // Talks to the user's Clay mate session via home_clay_* messages.
1
+ // Clay FAB + popover chat — phablet-style, persistent across the app.
2
+ // Self-contained: own DOM, own renderer, own WS protocol (home_clay_*).
4
3
  // Does not interfere with the active project session.
5
4
 
6
5
  import { escapeHtml } from './utils.js';
7
6
  import { getWs } from './ws-ref.js';
8
7
  import { renderMarkdown } from './markdown.js';
9
- import { refreshIcons } from './icons.js';
10
8
  import { switchProject } from './app-projects.js';
11
9
 
12
10
  var initialized = false;
11
+ var openState = false;
12
+ var fabBtn = null;
13
+ var popoverEl = null;
13
14
  var messagesEl = null;
14
15
  var inputEl = null;
15
16
  var sendBtn = null;
16
17
  var typingEl = null;
17
18
  var newBtnEl = null;
19
+ var closeBtnEl = null;
20
+
21
+ // Drag state for the FAB.
22
+ var FAB_POS_KEY = "clay-fab-pos";
23
+ var DRAG_THRESHOLD_PX = 5;
24
+ var dragState = null;
18
25
 
19
26
  // Per-turn assembly state. Server may emit many delta events for a single
20
27
  // assistant turn; we accumulate text and render incrementally into the
21
28
  // last bubble.
22
29
  var currentAssistantBubble = null;
23
30
  var currentAssistantText = "";
24
- var lastSenderWasUser = false;
31
+ var openedOnce = false; // gate the initial home_clay_open request
25
32
 
26
- // Initialize on first showHomeHub. The init function is exposed on
27
- // window.__initHomeChat so app-home-hub.js (which already imports too
28
- // many things) can call it without adding another import edge.
29
33
  export function initHomeChat() {
30
- if (initialized) {
31
- // Re-mount idempotent: just ensure the WS subscription is open.
32
- requestSession();
33
- return;
34
- }
34
+ if (initialized) return;
35
35
  initialized = true;
36
36
 
37
+ fabBtn = document.getElementById("clay-fab");
38
+ popoverEl = document.getElementById("clay-popover");
37
39
  messagesEl = document.getElementById("home-chat-messages");
38
40
  inputEl = document.getElementById("home-chat-input");
39
41
  sendBtn = document.getElementById("home-chat-send-btn");
40
42
  typingEl = document.getElementById("home-chat-typing");
41
43
  newBtnEl = document.getElementById("home-chat-new-btn");
44
+ closeBtnEl = document.getElementById("home-chat-close-btn");
45
+
46
+ if (!fabBtn || !popoverEl || !messagesEl || !inputEl || !sendBtn) return;
47
+
48
+ // --- Restore persisted FAB position ---
49
+ restoreFabPosition();
50
+ // Re-clamp on viewport resize so a saved position from a wider window
51
+ // doesn't strand the FAB off-screen.
52
+ window.addEventListener("resize", function () {
53
+ if (fabBtn.classList.contains("user-positioned")) clampFabIntoView();
54
+ });
55
+
56
+ // --- FAB drag + click ---
57
+ // mousedown/touchstart begins a potential drag. We only treat it as a
58
+ // click (toggle popover) if the pointer didn't move past DRAG_THRESHOLD_PX.
59
+ fabBtn.addEventListener("mousedown", onPointerDown);
60
+ fabBtn.addEventListener("touchstart", onPointerDown, { passive: false });
42
61
 
43
- if (!messagesEl || !inputEl || !sendBtn) return;
62
+ if (closeBtnEl) closeBtnEl.addEventListener("click", closePopover);
63
+
64
+ // ESC closes the popover.
65
+ document.addEventListener("keydown", function (e) {
66
+ if (e.key === "Escape" && openState) {
67
+ closePopover();
68
+ }
69
+ });
44
70
 
45
71
  // --- Input handling ---
46
72
  inputEl.addEventListener("input", function () {
@@ -62,19 +88,199 @@ export function initHomeChat() {
62
88
  messagesEl.innerHTML = "";
63
89
  currentAssistantBubble = null;
64
90
  currentAssistantText = "";
65
- lastSenderWasUser = false;
66
91
  hideTyping();
67
92
  addSystemBubble("New conversation started.");
68
93
  });
69
94
  }
95
+ }
96
+
97
+ // --- FAB drag mechanics ---
70
98
 
71
- // --- Initial state pull ---
72
- requestSession();
99
+ function getPointer(e) {
100
+ if (e.touches && e.touches.length > 0) {
101
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
102
+ }
103
+ return { x: e.clientX, y: e.clientY };
104
+ }
105
+
106
+ function onPointerDown(e) {
107
+ // Ignore right-clicks and modifier-clicks.
108
+ if (e.button && e.button !== 0) return;
109
+ var p = getPointer(e);
110
+ var rect = fabBtn.getBoundingClientRect();
111
+ dragState = {
112
+ startX: p.x,
113
+ startY: p.y,
114
+ offsetX: p.x - rect.left,
115
+ offsetY: p.y - rect.top,
116
+ moved: false,
117
+ };
118
+ document.addEventListener("mousemove", onPointerMove);
119
+ document.addEventListener("mouseup", onPointerUp);
120
+ document.addEventListener("touchmove", onPointerMove, { passive: false });
121
+ document.addEventListener("touchend", onPointerUp);
122
+ document.addEventListener("touchcancel", onPointerUp);
123
+ // Don't preventDefault yet — we let the browser distinguish between a
124
+ // tap (which should fire click → toggle) and a drag.
125
+ }
126
+
127
+ function onPointerMove(e) {
128
+ if (!dragState) return;
129
+ var p = getPointer(e);
130
+ var dx = p.x - dragState.startX;
131
+ var dy = p.y - dragState.startY;
132
+ if (!dragState.moved && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD_PX) {
133
+ return;
134
+ }
135
+ dragState.moved = true;
136
+ fabBtn.classList.add("dragging");
137
+ // Touch needs explicit prevent so the page doesn't scroll.
138
+ if (e.cancelable) e.preventDefault();
139
+ var x = p.x - dragState.offsetX;
140
+ var y = p.y - dragState.offsetY;
141
+ setFabPosition(x, y);
142
+ }
143
+
144
+ function onPointerUp() {
145
+ document.removeEventListener("mousemove", onPointerMove);
146
+ document.removeEventListener("mouseup", onPointerUp);
147
+ document.removeEventListener("touchmove", onPointerMove);
148
+ document.removeEventListener("touchend", onPointerUp);
149
+ document.removeEventListener("touchcancel", onPointerUp);
150
+ if (!dragState) return;
151
+ if (dragState.moved) {
152
+ fabBtn.classList.remove("dragging");
153
+ persistFabPosition();
154
+ clampFabIntoView();
155
+ } else {
156
+ // Pure tap → toggle popover.
157
+ toggleOpen();
158
+ }
159
+ dragState = null;
160
+ }
161
+
162
+ function setFabPosition(x, y) {
163
+ // Ensure the FAB stays within the viewport with a small margin.
164
+ var margin = 4;
165
+ var w = fabBtn.offsetWidth;
166
+ var h = fabBtn.offsetHeight;
167
+ var maxX = window.innerWidth - w - margin;
168
+ var maxY = window.innerHeight - h - margin;
169
+ if (x < margin) x = margin;
170
+ if (y < margin) y = margin;
171
+ if (x > maxX) x = maxX;
172
+ if (y > maxY) y = maxY;
173
+ fabBtn.classList.add("user-positioned");
174
+ fabBtn.style.left = x + "px";
175
+ fabBtn.style.top = y + "px";
176
+ fabBtn.style.right = "auto";
177
+ fabBtn.style.bottom = "auto";
178
+ // If the popover is open, keep it anchored to the FAB.
179
+ if (openState) anchorPopoverToFab();
180
+ }
181
+
182
+ function clampFabIntoView() {
183
+ if (!fabBtn) return;
184
+ var rect = fabBtn.getBoundingClientRect();
185
+ setFabPosition(rect.left, rect.top);
186
+ }
187
+
188
+ function persistFabPosition() {
189
+ if (!fabBtn) return;
190
+ try {
191
+ var rect = fabBtn.getBoundingClientRect();
192
+ localStorage.setItem(FAB_POS_KEY, JSON.stringify({ x: rect.left, y: rect.top }));
193
+ } catch (e) {}
194
+ }
195
+
196
+ function restoreFabPosition() {
197
+ try {
198
+ var raw = localStorage.getItem(FAB_POS_KEY);
199
+ if (!raw) return;
200
+ var pos = JSON.parse(raw);
201
+ if (pos && typeof pos.x === "number" && typeof pos.y === "number") {
202
+ setFabPosition(pos.x, pos.y);
203
+ }
204
+ } catch (e) {}
205
+ }
206
+
207
+ // Anchor the popover so its corner sits next to the FAB. We pick the
208
+ // corner that gives the most room — popover opens "into" the screen,
209
+ // not off-screen.
210
+ function anchorPopoverToFab() {
211
+ if (!fabBtn || !popoverEl) return;
212
+ var fr = fabBtn.getBoundingClientRect();
213
+ var pw = popoverEl.offsetWidth || 320;
214
+ var ph = popoverEl.offsetHeight || 480;
215
+ var margin = 12;
216
+ // Decide vertical: open above FAB if there's more room above.
217
+ var roomAbove = fr.top;
218
+ var roomBelow = window.innerHeight - fr.bottom;
219
+ var openUp = roomAbove >= roomBelow;
220
+ // Decide horizontal: align right edge of popover with right edge of FAB
221
+ // when the FAB is on the right half of the screen, else left edge.
222
+ var fabRightSide = (fr.left + fr.width / 2) > window.innerWidth / 2;
223
+
224
+ var top, left;
225
+ if (openUp) {
226
+ top = Math.max(margin, fr.top - ph - 8);
227
+ popoverEl.style.transformOrigin = fabRightSide ? "bottom right" : "bottom left";
228
+ } else {
229
+ top = Math.min(window.innerHeight - ph - margin, fr.bottom + 8);
230
+ popoverEl.style.transformOrigin = fabRightSide ? "top right" : "top left";
231
+ }
232
+ if (fabRightSide) {
233
+ left = Math.max(margin, fr.right - pw);
234
+ } else {
235
+ left = Math.min(window.innerWidth - pw - margin, fr.left);
236
+ }
237
+ popoverEl.style.top = top + "px";
238
+ popoverEl.style.left = left + "px";
239
+ popoverEl.style.right = "auto";
240
+ popoverEl.style.bottom = "auto";
241
+ }
242
+
243
+ function openPopover() {
244
+ if (!popoverEl || openState) return;
245
+ openState = true;
246
+ // Anchor BEFORE unhiding so the slide-up animation uses the correct
247
+ // transform-origin (top vs bottom, left vs right) for the FAB's
248
+ // current corner.
249
+ anchorPopoverToFab();
250
+ popoverEl.classList.remove("hidden");
251
+ if (fabBtn) fabBtn.classList.add("open");
252
+ // Pull session history on first open. If WS isn't ready yet, leave
253
+ // openedOnce false so the next open retries.
254
+ if (!openedOnce) {
255
+ var ws = getWs();
256
+ if (ws && ws.readyState === 1) {
257
+ openedOnce = true;
258
+ requestSession();
259
+ } else {
260
+ addSystemBubble("Connecting…");
261
+ }
262
+ }
263
+ // Focus the input so the user can start typing immediately.
264
+ setTimeout(function () { if (inputEl) inputEl.focus(); }, 60);
265
+ }
266
+
267
+ function closePopover() {
268
+ if (!openState) return;
269
+ openState = false;
270
+ if (popoverEl) popoverEl.classList.add("hidden");
271
+ if (fabBtn) {
272
+ fabBtn.classList.remove("open");
273
+ fabBtn.focus();
274
+ }
275
+ }
276
+
277
+ function toggleOpen() {
278
+ if (openState) closePopover(); else openPopover();
73
279
  }
74
280
 
75
281
  function autoResize() {
76
282
  inputEl.style.height = "auto";
77
- inputEl.style.height = Math.min(140, inputEl.scrollHeight) + "px";
283
+ inputEl.style.height = Math.min(120, inputEl.scrollHeight) + "px";
78
284
  }
79
285
 
80
286
  function requestSession() {
@@ -102,14 +308,12 @@ function doSend() {
102
308
  // --- Rendering ---
103
309
 
104
310
  function addUserBubble(text) {
105
- // Finalize any open assistant bubble before adding the next user turn.
106
311
  finalizeAssistant();
107
312
  var bubble = document.createElement("div");
108
313
  bubble.className = "home-chat-bubble home-chat-bubble-user";
109
314
  bubble.textContent = text;
110
315
  messagesEl.appendChild(bubble);
111
316
  scrollToBottom();
112
- lastSenderWasUser = true;
113
317
  }
114
318
 
115
319
  function ensureAssistantBubble() {
@@ -119,21 +323,18 @@ function ensureAssistantBubble() {
119
323
  messagesEl.appendChild(bubble);
120
324
  currentAssistantBubble = bubble;
121
325
  currentAssistantText = "";
122
- lastSenderWasUser = false;
123
326
  return bubble;
124
327
  }
125
328
 
126
329
  function appendAssistantText(text) {
127
330
  var bubble = ensureAssistantBubble();
128
331
  currentAssistantText += text;
129
- // Render markdown + linkify session refs after sanitization.
130
332
  bubble.innerHTML = linkifyRefs(renderMarkdown(currentAssistantText));
131
333
  scrollToBottom();
132
334
  }
133
335
 
134
336
  function finalizeAssistant() {
135
337
  if (currentAssistantBubble && !currentAssistantText) {
136
- // Empty assistant turn (no text produced). Drop the empty bubble.
137
338
  currentAssistantBubble.remove();
138
339
  }
139
340
  currentAssistantBubble = null;
@@ -148,11 +349,9 @@ function addSystemBubble(text) {
148
349
  scrollToBottom();
149
350
  }
150
351
 
151
- // Convert [project-slug/sess_xxx — date] tokens in the rendered HTML
152
- // into clickable chips. Server-side Clay is instructed to emit these.
153
352
  function linkifyRefs(html) {
154
- // Match [slug/sess_id - date] inside text but not inside HTML attributes.
155
- // Conservative: the slug is alphanumeric/-/_, sess id starts with sess_.
353
+ // Match [slug/sess_id - date]. Conservative: slug is alphanumeric/-/_,
354
+ // sess id starts with sess_.
156
355
  var re = /\[([a-zA-Z0-9_\-]+)\/(sess_[a-zA-Z0-9_\-]+)(?:\s+[—-]\s+([0-9]{4}-[0-9]{2}-[0-9]{2}))?\]/g;
157
356
  return html.replace(re, function (_full, slug, sessId, date) {
158
357
  var label = slug + "/" + sessId.substring(0, 14) + (date ? " · " + date : "");
@@ -162,18 +361,13 @@ function linkifyRefs(html) {
162
361
 
163
362
  function scrollToBottom() {
164
363
  if (!messagesEl) return;
165
- // Always pin: home chat is short, no need for scroll-up detection.
166
364
  requestAnimationFrame(function () {
167
365
  messagesEl.scrollTop = messagesEl.scrollHeight;
168
366
  });
169
367
  }
170
368
 
171
- function showTyping() {
172
- if (typingEl) typingEl.classList.remove("hidden");
173
- }
174
- function hideTyping() {
175
- if (typingEl) typingEl.classList.add("hidden");
176
- }
369
+ function showTyping() { if (typingEl) typingEl.classList.remove("hidden"); }
370
+ function hideTyping() { if (typingEl) typingEl.classList.add("hidden"); }
177
371
 
178
372
  // --- Server message handlers (called from app-messages.js dispatcher) ---
179
373
 
@@ -193,7 +387,6 @@ export function handleHomeClayHistory(msg) {
193
387
  if (e.role === "user") {
194
388
  addUserBubble(e.text || "");
195
389
  } else if (e.role === "assistant") {
196
- // Replay finalized assistant text in one shot.
197
390
  appendAssistantText(e.text || "");
198
391
  finalizeAssistant();
199
392
  }
@@ -223,17 +416,18 @@ document.addEventListener("click", function (e) {
223
416
  if (!chip) return;
224
417
  var slug = chip.dataset.slug;
225
418
  if (!slug) return;
226
- // Clicking a chip jumps the user out of the home hub into the source
227
- // project. Session selection inside that project is up to the existing
228
- // session restore mechanism.
419
+ closePopover();
229
420
  if (typeof switchProject === "function") {
230
- var hubBtn = document.getElementById("home-hub-close");
231
- if (hubBtn) hubBtn.click();
232
421
  switchProject(slug);
233
422
  }
234
423
  });
235
424
 
236
- // Expose init for app-home-hub.js without adding an import edge.
237
- if (typeof window !== "undefined") {
238
- window.__initHomeChat = initHomeChat;
425
+ // --- Initialize on DOM ready ---
426
+
427
+ if (typeof document !== "undefined") {
428
+ if (document.readyState === "loading") {
429
+ document.addEventListener("DOMContentLoaded", initHomeChat);
430
+ } else {
431
+ initHomeChat();
432
+ }
239
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.38.0-beta.3",
3
+ "version": "2.38.0-beta.5",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",