clay-server 4.5.1-beta.1 → 4.6.0

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.
@@ -5,7 +5,7 @@
5
5
  gap: 3px;
6
6
  flex: 1 1 auto;
7
7
  min-width: 0;
8
- overflow: hidden;
8
+ overflow: visible;
9
9
  }
10
10
  .session-work-column > .session-item-text,
11
11
  .session-work-column > .mobile-session-title {
@@ -27,6 +27,11 @@
27
27
  font-size: 11px;
28
28
  line-height: 18px;
29
29
  }
30
+ .session-work-column .session-row-github-links {
31
+ flex-wrap: wrap;
32
+ gap: 3px 5px;
33
+ overflow: visible;
34
+ }
30
35
  .session-github-link {
31
36
  display: inline-flex;
32
37
  align-items: center;
@@ -54,7 +59,11 @@
54
59
  .mobile-session-item .session-github-link { min-height: 28px; padding-right: 4px; }
55
60
  }
56
61
 
57
- .session-item:has(.session-work-column) { height: 52px; }
62
+ .session-item:has(.session-work-column) {
63
+ height: auto;
64
+ min-height: 52px;
65
+ contain: layout;
66
+ }
58
67
 
59
68
  .github-kind-pr.github-state-closed .github-work-status svg { color: #f85149; }
60
69
  @media (max-width: 600px) {
@@ -108,5 +117,6 @@
108
117
  .github-work-entry-meta { color: var(--text-muted, #888); font-size: 11px; overflow-wrap: anywhere; }
109
118
  @media (max-width: 600px) {
110
119
  #header-github-links { max-width: 120px; }
120
+ .mobile-session-item:has(.session-work-column) { align-items: flex-start; }
111
121
  .mobile-session-item .github-work-more { min-height: 28px; }
112
122
  }
@@ -28,6 +28,8 @@
28
28
  .stt-progress button { border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--text); font: inherit; cursor: pointer; padding: 5px 8px; }
29
29
  .stt-progress-active { flex: 1 1 0; flex-wrap: nowrap; padding: 0 8px 0 0; height: 62px; }
30
30
  .stt-progress-active .stt-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); min-width: 0; }
31
+ .stt-progress-active.stt-auto-send .stt-status { position: static; width: auto; height: auto; clip-path: none; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
+ .stt-progress-active.stt-auto-send .stt-meter { opacity: .58; }
31
33
  .stt-progress-pending .stt-status { position: static; width: auto; height: auto; clip-path: none; }
32
34
  .stt-progress-pending .stt-meter { display: none; }
33
35
  .stt-meter canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
@@ -1,4 +1,5 @@
1
1
  import { stopSTT } from './stt-controller.js';
2
+ import { hasRecordedSpeechFor, requestRecordedSend } from './stt-recording.js';
2
3
  // home-mate-chat.js - Embedded conversation surface for the selected mate.
3
4
  import { store } from './store.js';
4
5
  import { getWs } from './ws-ref.js';
@@ -86,6 +87,7 @@ function renderSessionModel() {
86
87
  sessionModelEl.setAttribute("aria-label", "Model for this conversation: " + label);
87
88
  }
88
89
  function submitMessage() {
90
+ if (requestRecordedSend(inputEl, submitMessage)) return true;
89
91
  stopSTT();
90
92
  var mateId = store.get('homeChatMateId');
91
93
  var text = inputEl ? inputEl.value.trim() : "";
@@ -146,7 +148,7 @@ function bindComposer() {
146
148
  bound = true;
147
149
  inputEl.addEventListener("input", function () {
148
150
  resizeInput();
149
- sendBtn.disabled = streaming || hasPendingHomeDebateQuestion(messages) || !store.get('homeChatMateId') || !hasCommittedSessionModel() || !inputEl.value.trim();
151
+ sendBtn.disabled = streaming || hasPendingHomeDebateQuestion(messages) || !store.get('homeChatMateId') || !hasCommittedSessionModel() || (!inputEl.value.trim() && !hasRecordedSpeechFor(inputEl));
150
152
  });
151
153
  bindHomeComposerSubmission(inputEl, sendBtn, submitMessage);
152
154
  sessionModelChooseEl.addEventListener("click", function () {
@@ -157,6 +159,10 @@ function bindComposer() {
157
159
  handleHomeMateModelConfirmed(event.detail || {});
158
160
  });
159
161
  }
162
+ store.subscribe(function (state, previous) {
163
+ if ((state.speechActive === previous.speechActive && state.speechStatuses === previous.speechStatuses) || !inputEl || !sendBtn) return;
164
+ renderHomeChat();
165
+ });
160
166
  function ensureDom() {
161
167
  if (!chatEl) chatEl = document.getElementById("home-mate-chat");
162
168
  if (!stageEl) stageEl = chatEl ? chatEl.querySelector(".home-mate-chat-stage") : null;
@@ -224,7 +230,7 @@ export function renderHomeChat() {
224
230
  inputEl.disabled = !mateId || awaitingQuestion || !hasCommittedSessionModel(); if (debateLaunching || mateCreationActive || !!debatePhase) inputEl.disabled = true;
225
231
  inputEl.placeholder = debateLaunching ? "Preparing your debate…" : (mateCreationActive ? "Use the interview cards above" : (debatePhase ? "Debate session" : (awaitingQuestion ? "Answer the question above to continue" : (mateId ? "Message " + mateName : "Message"))));
226
232
  sendBtn.disabled = !mateId || streaming || awaitingQuestion || !hasCommittedSessionModel();
227
- if (debateLaunching || mateCreationActive || !!debatePhase || !inputEl.value.trim()) sendBtn.disabled = true;
233
+ if (debateLaunching || mateCreationActive || !!debatePhase || (!inputEl.value.trim() && !hasRecordedSpeechFor(inputEl))) sendBtn.disabled = true;
228
234
  renderSessionModel();
229
235
  renderHomeDebateControls(messages, activeSessionRequestId, function () { store.set({ homeDebateTopicFormOpen: true }); setHomeSubSurface("debates"); });
230
236
  if (animateSwitch) {
@@ -1,4 +1,5 @@
1
1
  import { stopSTT } from './stt-controller.js';
2
+ import { hasRecordedSpeechFor, requestRecordedSend } from './stt-recording.js';
2
3
  import { iconHtml, refreshIcons } from './icons.js';
3
4
  import { setRewindMode, isRewindMode } from './rewind.js';
4
5
  import { renderPicker as renderContextPicker } from './context-sources.js';
@@ -34,7 +35,8 @@ export function hasSendableContent() {
34
35
  (ctx && ctx.inputEl && ctx.inputEl.value.trim()) ||
35
36
  pendingPastes.length > 0 ||
36
37
  pendingImages.length > 0 ||
37
- pendingFiles.length > 0
38
+ pendingFiles.length > 0 ||
39
+ (ctx && hasRecordedSpeechFor(ctx.inputEl))
38
40
  );
39
41
  }
40
42
 
@@ -102,6 +104,11 @@ store.subscribe(function (state, previous) {
102
104
  if (Object.keys(scheduleAdmissionMap()).length > 0) store.set({ pendingScheduleAdmissions: {}, pendingScheduleAdmission: null });
103
105
  });
104
106
 
107
+ store.subscribe(function (state, previous) {
108
+ if ((state.speechActive === previous.speechActive && state.speechStatuses === previous.speechStatuses) || !ctx || !ctx.processing || !ctx.setSendBtnMode) return;
109
+ ctx.setSendBtnMode(hasSendableContent() ? 'send' : 'stop');
110
+ });
111
+
105
112
  function showPreThinkingForTurn() {
106
113
  if (ctx.isMateDm && ctx.isMateDm()) {
107
114
  ctx.showMatePreThinking();
@@ -112,6 +119,7 @@ function showPreThinkingForTurn() {
112
119
 
113
120
  // --- Send ---
114
121
  export function sendMessage() {
122
+ if (ctx && requestRecordedSend(ctx.inputEl, sendMessage)) return;
115
123
  stopSTT();
116
124
  // Debate ended mode intercept: route to resume handler
117
125
  if (ctx.isDebateEndedMode && ctx.isDebateEndedMode() && ctx.handleDebateEndedSend) {
@@ -26,10 +26,17 @@ export function orderedGithubWork(session) {
26
26
  if (latestIssue) links = [latestIssue].concat(links.filter(function (link) { return link !== latestIssue; }));
27
27
  return links;
28
28
  }
29
- export function githubWorkMarkup(session, detailed, expanded) {
29
+ function compactGithubWork(links, sessionRow) {
30
+ if (!sessionRow) return links.slice(0, 1);
31
+ var latestIssue = links.find(function (link) { return link.kind === 'issue'; });
32
+ var latestPr = links.find(function (link) { return link.kind === 'pr'; });
33
+ return [latestIssue, latestPr].filter(Boolean);
34
+ }
35
+ export function githubWorkMarkup(session, detailed, expanded, sessionRow) {
30
36
  var links = orderedGithubWork(session);
31
37
  if (!links.length) return '';
32
- return '<span class="session-github-links' + (detailed ? ' github-work-details' : '') + '">' + (detailed || expanded ? links : links.slice(0, 1)).map(function (link) {
38
+ var displayed = detailed || expanded ? links : compactGithubWork(links, sessionRow);
39
+ return '<span class="session-github-links' + (detailed ? ' github-work-details' : '') + (sessionRow ? ' session-row-github-links' : '') + '">' + displayed.map(function (link) {
33
40
  var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown';
34
41
  var icon = link.kind === 'pr' ? (state === 'merged' ? 'git-merge' : state === 'closed' ? 'git-pull-request-closed' : state === 'draft' ? 'git-pull-request-draft' : 'git-pull-request') : (state === 'closed' ? 'circle-check' : 'circle-dot');
35
42
  var description = link.repository + ' · ' + label(link) + ' · ' + link.title + ' · ' + state + (link.checkedAt ? ' · Checked ' + new Date(link.checkedAt).toLocaleString() : '') + (link.stale ? ' (last known status; refresh unavailable)' : '');
@@ -43,10 +50,10 @@ export function githubWorkMarkup(session, detailed, expanded) {
43
50
  });
44
51
  }
45
52
  return html;
46
- }).join('') + (!detailed && !expanded && links.length > 1 ? '<button type="button" class="github-work-more" aria-label="Show all ' + links.length + ' linked GitHub items" aria-haspopup="dialog" aria-expanded="false" data-github-links="' + escapeHtml(JSON.stringify(safeLinks(session))) + '">+' + (links.length - 1) + '</button>' : '') + '</span>';
53
+ }).join('') + (!detailed && !expanded && links.length > displayed.length ? '<button type="button" class="github-work-more" aria-label="Show all ' + links.length + ' linked GitHub items" aria-haspopup="dialog" aria-expanded="false" data-github-links="' + escapeHtml(JSON.stringify(safeLinks(session))) + '">+' + (links.length - displayed.length) + '</button>' : '') + '</span>';
47
54
  }
48
55
  export function appendGithubWork(row, session, title) {
49
- var html = githubWorkMarkup(session, false);
56
+ var html = githubWorkMarkup(session, false, false, true);
50
57
  bindGithubWorkPopover();
51
58
  if (!html) return;
52
59
  var column = document.createElement('span');
@@ -342,7 +342,10 @@ function handleSessionDragStart(event) {
342
342
 
343
343
  function handleSidebarSessionClick(event) {
344
344
  if (!store.get('splitPanes') || event.button !== 0) return;
345
- if (event.target.closest(".session-close-btn, .session-more-btn")) return;
345
+ // This document capture listener runs before a row's bubble handlers. Leave
346
+ // real controls alone so GitHub anchors retain native click, modified-click,
347
+ // and keyboard navigation rather than selecting their owning session.
348
+ if (event.target.closest(".session-close-btn, .session-more-btn, .session-github-link, .github-work-more")) return;
346
349
  var item = event.target.closest(".session-item[data-session-id], .session-loop-child[data-session-id]");
347
350
  if (!item) return;
348
351
  var sessionId = parseInt(item.dataset.sessionId, 10);
@@ -19,7 +19,12 @@ export function createSpeechMeter(id) {
19
19
  canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
20
20
  }
21
21
  context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height);
22
- context.fillStyle = getComputedStyle(element).color;
22
+ var style = getComputedStyle(element);
23
+ var gradient = context.createLinearGradient(0, 0, width, 0);
24
+ gradient.addColorStop(0, style.getPropertyValue('--brand-green').trim() || style.color);
25
+ gradient.addColorStop(0.5, style.getPropertyValue('--brand-blue').trim() || style.color);
26
+ gradient.addColorStop(1, style.getPropertyValue('--brand-green').trim() || style.color);
27
+ context.fillStyle = gradient;
23
28
  var signal = active.audioSignal; var levels = signal ? signal.levels : [];
24
29
  var age = signal ? Date.now() - signal.updatedAt : 0;
25
30
  var fade = Math.max(0, 1 - Math.max(0, age - 250) / 900);
@@ -7,7 +7,7 @@ import { claimSpeech, currentSpeech, writeSpeech, usableSpeechInput, setSpeechSt
7
7
  async function upload(active, blob) {
8
8
  if (!currentSpeech(active)) return;
9
9
  active.phase = 'transcribing'; store.set({ speechActive: active });
10
- setSpeechStatus(active.id, 'Transcribing…');
10
+ setSpeechStatus(active.id, active.autoSend ? 'Transcribing before sending…' : 'Transcribing…');
11
11
  speechTrace(active, 'upload-start', { bytes: blob.size });
12
12
  try {
13
13
  var response = await fetch('/api/speech/transcribe', { method: 'POST', signal: active.abortController.signal,
@@ -16,8 +16,10 @@ async function upload(active, blob) {
16
16
  speechTrace(active, 'upload-response', { status: response.status, transcriptCharacters: typeof result.text === 'string' ? result.text.length : 0 });
17
17
  if (!response.ok) throw new Error(result.error || 'Transcription failed. Please try again.');
18
18
  if (!currentSpeech(active)) return;
19
- writeSpeech(active, result.text || '');
20
- stopSTT(result.text ? 'Review your draft before sending.' : 'No speech detected. Try recording again.');
19
+ var transcript = typeof result.text === 'string' ? result.text : '';
20
+ var accepted = writeSpeech(active, transcript);
21
+ stopSTT(transcript ? (active.autoSend ? 'Sending transcribed voice input…' : 'Review your draft before sending.') : 'No speech detected. Try recording again.');
22
+ return !!(accepted && transcript.trim());
21
23
  } catch (error) {
22
24
  if (!currentSpeech(active)) return;
23
25
  var retry = { id: active.id, input: active.input, context: active.context, value: active.lastValue,
@@ -25,6 +27,7 @@ async function upload(active, blob) {
25
27
  failSTT(error.name === 'AbortError' ? 'Transcription timed out. Please try again.' : error.message);
26
28
  store.set({ speechRetry: retry });
27
29
  setTimeout(function () { if (store.get('speechRetry') === retry) store.set({ speechRetry: null }); }, 300000);
30
+ return false;
28
31
  }
29
32
  }
30
33
  function initialize(id, input, model, language) {
@@ -103,8 +106,8 @@ export async function startRecording(id, input, model) {
103
106
  }
104
107
  export async function finishRecording() {
105
108
  var active = store.get('speechActive');
106
- if (!active || !active.model) { stopSTT(); return; }
107
- if (active.phase !== 'recording') return;
109
+ if (!active || !active.model) { stopSTT(); return false; }
110
+ if (active.phase !== 'recording') return false;
108
111
  speechTrace(active, 'finish-requested', { bytes: active.bytes });
109
112
  active.phase = 'finishing'; clearInterval(active.clock); store.set({ speechActive: active });
110
113
  await active.capture.finish();
@@ -117,9 +120,33 @@ export async function finishRecording() {
117
120
  } else {
118
121
  var blob = wavRecording(active.chunks); active.chunks = [];
119
122
  active.timeout = setTimeout(function () { active.abortController.abort(); }, 125000);
120
- await upload(active, blob);
123
+ return upload(active, blob);
121
124
  }
122
125
  }
126
+ export function hasRecordedSpeechFor(input) {
127
+ var active = store.get('speechActive');
128
+ return !!(active && active.input === input && active.model && active.model.mode === 'recorded' &&
129
+ (active.phase === 'recording' || active.autoSend));
130
+ }
131
+ export function requestRecordedSend(input, send) {
132
+ var active = store.get('speechActive');
133
+ if (!active || active.input !== input || !active.model || active.model.mode !== 'recorded') return false;
134
+ if (active.autoSend) return true;
135
+ if (active.phase !== 'recording') return false;
136
+ active.autoSend = true;
137
+ store.set({ speechActive: active });
138
+ setSpeechStatus(active.id, 'Transcribing before sending…');
139
+ finishRecording().then(function (sendable) {
140
+ if (!sendable || !active.autoSend || active.context !== speechContext() ||
141
+ !usableSpeechInput(active.input) || active.input.value !== active.lastValue || store.get('speechActive')) return;
142
+ active.autoSend = false;
143
+ send();
144
+ }, function (error) {
145
+ if (!currentSpeech(active)) return;
146
+ failSTT(error && error.message ? error.message : 'Voice input could not finish. Please try again.');
147
+ });
148
+ return true;
149
+ }
123
150
  export function retryRecording() {
124
151
  var retry = store.get('speechRetry');
125
152
  if (!retry || retry.context !== speechContext() || retry.input.value !== retry.value || !usableSpeechInput(retry.input)) { store.set({ speechRetry: null }); return; }
@@ -72,7 +72,9 @@ function bindComposer(id, inputId, wrapperId) {
72
72
  var model = voiceModel(); var provider = voiceProvider(model.provider);
73
73
  var browser = model.provider === 'browser' ? browserSpeechInfo() : null;
74
74
  var pending = own && active.model && active.phase !== 'recording';
75
+ var autoSend = own && !!active.autoSend;
75
76
  composer.classList.toggle('stt-recording', own);
77
+ row.classList.toggle('stt-auto-send', autoSend);
76
78
  row.classList.toggle('stt-progress-active', own);
77
79
  row.classList.toggle('stt-progress-pending', !!pending);
78
80
  var parent = own ? toolbar : composer;
@@ -83,7 +85,7 @@ function bindComposer(id, inputId, wrapperId) {
83
85
  picker.disabled = input.disabled || input.readOnly || own;
84
86
  button.classList.toggle('stt-active', own);
85
87
  button.setAttribute('aria-pressed', String(own));
86
- var action = own ? (pending ? 'Cancel voice input' : (model.mode === 'recorded' ? 'Stop and transcribe' : 'Stop voice input')) : 'Start voice input';
88
+ var action = own ? (autoSend ? 'Cancel pending voice send' : (pending ? 'Cancel voice input' : (model.mode === 'recorded' ? 'Stop and transcribe' : 'Stop voice input'))) : 'Start voice input';
87
89
  button.setAttribute('aria-label', action + ' · ' + (provider ? provider.name + ' · ' : '') + model.name);
88
90
  button.title = action + ' · ' + model.name;
89
91
  var signature = [own, pending, provider && provider.id, browser && browser.id].join(':');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.5.1-beta.1",
3
+ "version": "4.6.0",
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"