@agent-link/server 0.1.157 → 0.1.159

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.
package/web/landing.html CHANGED
@@ -668,6 +668,60 @@
668
668
  .bento-icon.amber { background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.05)); color: var(--amber); }
669
669
  .bento-icon.blue { background: linear-gradient(135deg, rgba(96,165,250,0.2), rgba(96,165,250,0.05)); color: var(--blue); }
670
670
 
671
+ /* Mode toggle visual */
672
+ .mode-toggle {
673
+ display: flex;
674
+ gap: 0;
675
+ background: rgba(255,255,255,0.04);
676
+ border: 1px solid var(--border);
677
+ border-radius: 12px;
678
+ overflow: hidden;
679
+ }
680
+
681
+ .mode-toggle-item {
682
+ flex: 1;
683
+ text-align: center;
684
+ padding: 0.7rem 1rem;
685
+ position: relative;
686
+ transition: all 0.3s;
687
+ }
688
+
689
+ .mode-toggle-item:not(:last-child) {
690
+ border-right: 1px solid var(--border);
691
+ }
692
+
693
+ .mode-toggle-item.active {
694
+ background: rgba(59,130,246,0.12);
695
+ }
696
+
697
+ .mode-toggle-item .mode-icon {
698
+ width: 32px; height: 32px;
699
+ margin: 0 auto 0.4rem;
700
+ border-radius: 8px;
701
+ display: flex;
702
+ align-items: center;
703
+ justify-content: center;
704
+ }
705
+
706
+ .mode-toggle-item .mode-icon svg { width: 18px; height: 18px; }
707
+
708
+ .mode-toggle-item .mode-icon.chat-icon { background: rgba(59,130,246,0.15); color: var(--accent); }
709
+ .mode-toggle-item .mode-icon.team-icon { background: rgba(96,165,250,0.15); color: var(--blue); }
710
+ .mode-toggle-item .mode-icon.loop-icon { background: rgba(52,211,153,0.15); color: var(--green); }
711
+
712
+ .mode-toggle-item .mode-name {
713
+ font-size: 0.8rem;
714
+ font-weight: 600;
715
+ margin-bottom: 0.2rem;
716
+ color: var(--text);
717
+ }
718
+
719
+ .mode-toggle-item .mode-desc {
720
+ font-size: 0.68rem;
721
+ color: var(--text-dim);
722
+ line-height: 1.4;
723
+ }
724
+
671
725
  /* Encryption visual */
672
726
  .encrypt-visual {
673
727
  display: flex;
@@ -1142,33 +1196,35 @@
1142
1196
  <p>Phone, tablet, laptop &mdash; any browser becomes your Claude Code terminal.</p>
1143
1197
  </div>
1144
1198
 
1145
- <!-- Full Claude Code — wide -->
1199
+ <!-- Chat | Team | Loop — wide -->
1146
1200
  <div class="bento-card wide">
1147
1201
  <div class="bento-visual">
1148
- <div class="device-flow">
1149
- <div class="device-pill">
1150
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>
1151
- Terminal
1152
- </div>
1153
- <div class="flow-line"></div>
1154
- <div class="device-pill">
1155
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
1156
- Files
1202
+ <div class="mode-toggle">
1203
+ <div class="mode-toggle-item active">
1204
+ <div class="mode-icon chat-icon">
1205
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
1206
+ </div>
1207
+ <div class="mode-name">Chat</div>
1208
+ <div class="mode-desc">Interactive coding sessions</div>
1157
1209
  </div>
1158
- <div class="flow-line"></div>
1159
- <div class="device-pill">
1160
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
1161
- Search
1210
+ <div class="mode-toggle-item">
1211
+ <div class="mode-icon team-icon">
1212
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
1213
+ </div>
1214
+ <div class="mode-name">Team</div>
1215
+ <div class="mode-desc">Parallel sub-agents</div>
1162
1216
  </div>
1163
- <div class="flow-line"></div>
1164
- <div class="device-pill">
1165
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
1166
- Chat
1217
+ <div class="mode-toggle-item">
1218
+ <div class="mode-icon loop-icon">
1219
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
1220
+ </div>
1221
+ <div class="mode-name">Loop</div>
1222
+ <div class="mode-desc">Scheduled cron tasks</div>
1167
1223
  </div>
1168
1224
  </div>
1169
1225
  </div>
1170
- <h3>Full Claude Code Experience</h3>
1171
- <p>File editing, shell commands, search, multi-turn conversations &mdash; every capability, now in your browser.</p>
1226
+ <h3>Three Modes, One Interface</h3>
1227
+ <p>Chat with Claude interactively, spawn agent teams for complex tasks, or schedule recurring Loop jobs with cron &mdash; all from the same browser tab.</p>
1172
1228
  </div>
1173
1229
 
1174
1230
  <!-- Self-hostable -->
@@ -669,6 +669,60 @@
669
669
  .bento-icon.amber { background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.05)); color: var(--amber); }
670
670
  .bento-icon.blue { background: linear-gradient(135deg, rgba(96,165,250,0.2), rgba(96,165,250,0.05)); color: var(--blue); }
671
671
 
672
+ /* Mode toggle visual */
673
+ .mode-toggle {
674
+ display: flex;
675
+ gap: 0;
676
+ background: rgba(255,255,255,0.04);
677
+ border: 1px solid var(--border);
678
+ border-radius: 12px;
679
+ overflow: hidden;
680
+ }
681
+
682
+ .mode-toggle-item {
683
+ flex: 1;
684
+ text-align: center;
685
+ padding: 0.7rem 1rem;
686
+ position: relative;
687
+ transition: all 0.3s;
688
+ }
689
+
690
+ .mode-toggle-item:not(:last-child) {
691
+ border-right: 1px solid var(--border);
692
+ }
693
+
694
+ .mode-toggle-item.active {
695
+ background: rgba(59,130,246,0.12);
696
+ }
697
+
698
+ .mode-toggle-item .mode-icon {
699
+ width: 32px; height: 32px;
700
+ margin: 0 auto 0.4rem;
701
+ border-radius: 8px;
702
+ display: flex;
703
+ align-items: center;
704
+ justify-content: center;
705
+ }
706
+
707
+ .mode-toggle-item .mode-icon svg { width: 18px; height: 18px; }
708
+
709
+ .mode-toggle-item .mode-icon.chat-icon { background: rgba(59,130,246,0.15); color: var(--accent); }
710
+ .mode-toggle-item .mode-icon.team-icon { background: rgba(96,165,250,0.15); color: var(--blue); }
711
+ .mode-toggle-item .mode-icon.loop-icon { background: rgba(52,211,153,0.15); color: var(--green); }
712
+
713
+ .mode-toggle-item .mode-name {
714
+ font-size: 0.8rem;
715
+ font-weight: 600;
716
+ margin-bottom: 0.2rem;
717
+ color: var(--text);
718
+ }
719
+
720
+ .mode-toggle-item .mode-desc {
721
+ font-size: 0.68rem;
722
+ color: var(--text-dim);
723
+ line-height: 1.4;
724
+ }
725
+
672
726
  /* Encryption visual */
673
727
  .encrypt-visual {
674
728
  display: flex;
@@ -1141,33 +1195,35 @@
1141
1195
  <p>手机、平板、笔记本 &mdash; 有浏览器就行,随时切到你的 Claude。</p>
1142
1196
  </div>
1143
1197
 
1144
- <!-- Full Claude Code — wide -->
1198
+ <!-- Chat | Team | Loop — wide -->
1145
1199
  <div class="bento-card wide">
1146
1200
  <div class="bento-visual">
1147
- <div class="device-flow">
1148
- <div class="device-pill">
1149
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>
1150
- 终端
1151
- </div>
1152
- <div class="flow-line"></div>
1153
- <div class="device-pill">
1154
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
1155
- 文件
1201
+ <div class="mode-toggle">
1202
+ <div class="mode-toggle-item active">
1203
+ <div class="mode-icon chat-icon">
1204
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
1205
+ </div>
1206
+ <div class="mode-name">Chat</div>
1207
+ <div class="mode-desc">交互式编程对话</div>
1156
1208
  </div>
1157
- <div class="flow-line"></div>
1158
- <div class="device-pill">
1159
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
1160
- 搜索
1209
+ <div class="mode-toggle-item">
1210
+ <div class="mode-icon team-icon">
1211
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
1212
+ </div>
1213
+ <div class="mode-name">Team</div>
1214
+ <div class="mode-desc">并行子 Agent 协作</div>
1161
1215
  </div>
1162
- <div class="flow-line"></div>
1163
- <div class="device-pill">
1164
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
1165
- 对话
1216
+ <div class="mode-toggle-item">
1217
+ <div class="mode-icon loop-icon">
1218
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
1219
+ </div>
1220
+ <div class="mode-name">Loop</div>
1221
+ <div class="mode-desc">定时 Cron 任务</div>
1166
1222
  </div>
1167
1223
  </div>
1168
1224
  </div>
1169
- <h3>完整的 Claude Code 体验</h3>
1170
- <p>文件编辑、Shell 命令、代码搜索、多轮对话 &mdash; 所有能力,现在搬到了浏览器里。</p>
1225
+ <h3>三大模式,一个界面</h3>
1226
+ <p>和 Claude 交互对话、启动 Agent 团队处理复杂任务、或用 Cron 调度定时 Loop 任务 &mdash; 全在同一个浏览器标签页里。</p>
1171
1227
  </div>
1172
1228
 
1173
1229
  <!-- Self-hostable -->
@@ -45,7 +45,8 @@ export function buildHistoryBatch(history, nextId) {
45
45
  batch.push({
46
46
  id: nextId(), role: 'tool',
47
47
  toolId: h.toolId || '', toolName: h.toolName || 'unknown',
48
- toolInput: h.toolInput || '', hasResult: true,
48
+ toolInput: h.toolInput || '', hasResult: !!h.toolOutput,
49
+ toolOutput: h.toolOutput || '',
49
50
  expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'),
50
51
  timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
51
52
  });
@@ -43,6 +43,10 @@ export function createConnection(deps) {
43
43
  let team = null;
44
44
  function setTeam(t) { team = t; }
45
45
 
46
+ // Loop module — set after creation to resolve circular dependency
47
+ let loop = null;
48
+ function setLoop(l) { loop = l; }
49
+
46
50
  let ws = null;
47
51
  let sessionKey = null;
48
52
  let reconnectAttempts = 0;
@@ -230,6 +234,12 @@ export function createConnection(deps) {
230
234
  return;
231
235
  }
232
236
 
237
+ // ── Loop messages: route before normal conversation routing ──
238
+ if (loop && (msg.type?.startsWith('loop_') || msg.type === 'loops_list')) {
239
+ loop.handleLoopMessage(msg);
240
+ return;
241
+ }
242
+
233
243
  // ── Multi-session: route messages to background conversations ──
234
244
  // Messages with a conversationId that doesn't match the current foreground
235
245
  // conversation are routed to their cached background state.
@@ -268,6 +278,7 @@ export function createConnection(deps) {
268
278
  }
269
279
  sidebar.requestSessionList();
270
280
  if (team) team.requestTeamsList();
281
+ if (loop) loop.requestLoopsList();
271
282
  startPing();
272
283
  wsSend({ type: 'query_active_conversations' });
273
284
  } else {
@@ -312,6 +323,7 @@ export function createConnection(deps) {
312
323
  }
313
324
  sidebar.requestSessionList();
314
325
  if (team) team.requestTeamsList();
326
+ if (loop) loop.requestLoopsList();
315
327
  startPing();
316
328
  wsSend({ type: 'query_active_conversations' });
317
329
  } else if (msg.type === 'active_conversations') {
@@ -386,6 +398,10 @@ export function createConnection(deps) {
386
398
  if (currentConversationId && currentConversationId.value) {
387
399
  processingConversations.value[currentConversationId.value] = false;
388
400
  }
401
+ // Forward error to Loop module for inline display
402
+ if (loop && loop.loopError) {
403
+ loop.loopError.value = msg.message || '';
404
+ }
389
405
  _dequeueNext();
390
406
  } else if (msg.type === 'claude_output') {
391
407
  handleClaudeOutput(msg, scheduleHighlight);
@@ -551,8 +567,13 @@ export function createConnection(deps) {
551
567
  content: 'Working directory changed to: ' + msg.workDir,
552
568
  timestamp: new Date(),
553
569
  });
570
+ // Clear old history immediately so UI doesn't show stale data
571
+ historySessions.value = [];
572
+ if (team) team.teamsList.value = [];
573
+ if (loop) loop.loopsList.value = [];
554
574
  sidebar.requestSessionList();
555
575
  if (team) team.requestTeamsList();
576
+ if (loop) loop.requestLoopsList();
556
577
  }
557
578
  };
558
579
 
@@ -603,5 +624,5 @@ export function createConnection(deps) {
603
624
  ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
604
625
  }
605
626
 
606
- return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, setTeam, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
627
+ return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, setTeam, setLoop, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
607
628
  }
@@ -0,0 +1,337 @@
1
+ // ── Loop mode: state management and message routing ───────────────────────────
2
+ const { ref, computed } = Vue;
3
+
4
+ import { buildHistoryBatch } from './backgroundRouting.js';
5
+
6
+ /**
7
+ * Creates the Loop mode controller.
8
+ * @param {object} deps
9
+ * @param {Function} deps.wsSend
10
+ * @param {Function} deps.scrollToBottom
11
+ */
12
+ export function createLoop(deps) {
13
+ const { wsSend, scrollToBottom } = deps;
14
+
15
+ // ── Reactive state ──────────────────────────────────
16
+
17
+ /** @type {import('vue').Ref<Array>} All Loop definitions from agent */
18
+ const loopsList = ref([]);
19
+
20
+ /** @type {import('vue').Ref<object|null>} Loop selected for detail view */
21
+ const selectedLoop = ref(null);
22
+
23
+ /** @type {import('vue').Ref<string|null>} Execution ID selected for replay */
24
+ const selectedExecution = ref(null);
25
+
26
+ /** @type {import('vue').Ref<Array>} Execution history for selectedLoop */
27
+ const executionHistory = ref([]);
28
+
29
+ /** @type {import('vue').Ref<Array>} Messages for selectedExecution replay */
30
+ const executionMessages = ref([]);
31
+
32
+ /** @type {import('vue').Ref<object>} loopId -> LoopExecution for currently running */
33
+ const runningLoops = ref({});
34
+
35
+ /** @type {import('vue').Ref<boolean>} Loading execution list */
36
+ const loadingExecutions = ref(false);
37
+
38
+ /** @type {import('vue').Ref<boolean>} Loading single execution detail */
39
+ const loadingExecution = ref(false);
40
+
41
+ /** @type {import('vue').Ref<string|null>} Loop being edited (loopId) or null for new */
42
+ const editingLoopId = ref(null);
43
+
44
+ /** @type {import('vue').Ref<string>} Error message from last loop operation (create/update) */
45
+ const loopError = ref('');
46
+
47
+ /** @type {number} Current execution history page limit */
48
+ let execPageLimit = 20;
49
+
50
+ /** @type {import('vue').Ref<boolean>} Whether more execution history may be available */
51
+ const hasMoreExecutions = ref(false);
52
+
53
+ /** @type {import('vue').Ref<boolean>} Loading more executions via pagination */
54
+ const loadingMoreExecutions = ref(false);
55
+
56
+ // ── Computed ──────────────────────────────────────
57
+
58
+ /** Whether any Loop execution is currently running */
59
+ const hasRunningLoop = computed(() => Object.keys(runningLoops.value).length > 0);
60
+
61
+ /** Get the first running loop for notification banner */
62
+ const firstRunningLoop = computed(() => {
63
+ const entries = Object.entries(runningLoops.value);
64
+ if (entries.length === 0) return null;
65
+ const [loopId, execution] = entries[0];
66
+ const loop = loopsList.value.find(l => l.id === loopId);
67
+ return { loopId, execution, name: loop?.name || 'Unknown' };
68
+ });
69
+
70
+ // ── Loop CRUD ─────────────────────────────────────
71
+
72
+ function createNewLoop(config) {
73
+ wsSend({ type: 'create_loop', ...config });
74
+ }
75
+
76
+ function updateExistingLoop(loopId, updates) {
77
+ wsSend({ type: 'update_loop', loopId, updates });
78
+ }
79
+
80
+ function deleteExistingLoop(loopId) {
81
+ wsSend({ type: 'delete_loop', loopId });
82
+ }
83
+
84
+ function toggleLoop(loopId) {
85
+ const loop = loopsList.value.find(l => l.id === loopId);
86
+ if (!loop) return;
87
+ wsSend({ type: 'update_loop', loopId, updates: { enabled: !loop.enabled } });
88
+ }
89
+
90
+ function runNow(loopId) {
91
+ wsSend({ type: 'run_loop', loopId });
92
+ }
93
+
94
+ function cancelExecution(loopId) {
95
+ wsSend({ type: 'cancel_loop_execution', loopId });
96
+ }
97
+
98
+ function requestLoopsList() {
99
+ wsSend({ type: 'list_loops' });
100
+ }
101
+
102
+ // ── Navigation ────────────────────────────────────
103
+
104
+ function viewLoopDetail(loopId) {
105
+ const loop = loopsList.value.find(l => l.id === loopId);
106
+ if (!loop) return;
107
+ selectedLoop.value = { ...loop };
108
+ selectedExecution.value = null;
109
+ executionMessages.value = [];
110
+ executionHistory.value = [];
111
+ loadingExecutions.value = true;
112
+ editingLoopId.value = null;
113
+ execPageLimit = 20;
114
+ hasMoreExecutions.value = false;
115
+ wsSend({ type: 'list_loop_executions', loopId, limit: execPageLimit });
116
+ }
117
+
118
+ function viewExecution(loopId, executionId) {
119
+ selectedExecution.value = executionId;
120
+ loadingExecution.value = true;
121
+ executionMessages.value = [];
122
+ wsSend({ type: 'get_loop_execution_messages', loopId, executionId });
123
+ }
124
+
125
+ function backToLoopsList() {
126
+ selectedLoop.value = null;
127
+ selectedExecution.value = null;
128
+ executionHistory.value = [];
129
+ executionMessages.value = [];
130
+ editingLoopId.value = null;
131
+ }
132
+
133
+ function backToLoopDetail() {
134
+ selectedExecution.value = null;
135
+ executionMessages.value = [];
136
+ }
137
+
138
+ function startEditing(loopId) {
139
+ editingLoopId.value = loopId;
140
+ }
141
+
142
+ function cancelEditing() {
143
+ editingLoopId.value = null;
144
+ }
145
+
146
+ function loadMoreExecutions() {
147
+ if (!selectedLoop.value || loadingMoreExecutions.value) return;
148
+ loadingMoreExecutions.value = true;
149
+ execPageLimit *= 2;
150
+ wsSend({ type: 'list_loop_executions', loopId: selectedLoop.value.id, limit: execPageLimit });
151
+ }
152
+
153
+ function clearLoopError() {
154
+ loopError.value = '';
155
+ }
156
+
157
+ // ── Live output accumulation ─────────────────────
158
+
159
+ /** Message ID counter for live execution messages */
160
+ let liveMsgIdCounter = 0;
161
+
162
+ /**
163
+ * Append a Claude output message to the live execution display.
164
+ * Mirrors the team.js handleTeamAgentOutput accumulation logic.
165
+ */
166
+ function appendOutputToDisplay(data) {
167
+ if (!data) return;
168
+ const msgs = executionMessages.value;
169
+
170
+ if (data.type === 'content_block_delta' && data.delta) {
171
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
172
+ if (last && last.role === 'assistant' && last.isStreaming) {
173
+ last.content += data.delta;
174
+ } else {
175
+ msgs.push({
176
+ id: ++liveMsgIdCounter, role: 'assistant',
177
+ content: data.delta, isStreaming: true, timestamp: Date.now(),
178
+ });
179
+ }
180
+ } else if (data.type === 'tool_use' && data.tools) {
181
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
182
+ if (last && last.role === 'assistant' && last.isStreaming) {
183
+ last.isStreaming = false;
184
+ }
185
+ for (const tool of data.tools) {
186
+ msgs.push({
187
+ id: ++liveMsgIdCounter, role: 'tool',
188
+ toolId: tool.id, toolName: tool.name || 'unknown',
189
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
190
+ hasResult: false, expanded: true, timestamp: Date.now(),
191
+ });
192
+ }
193
+ } else if (data.type === 'user' && data.tool_use_result) {
194
+ const result = data.tool_use_result;
195
+ const results = Array.isArray(result) ? result : [result];
196
+ for (const r of results) {
197
+ const toolMsg = msgs.find(m => m.role === 'tool' && m.toolId === r.tool_use_id);
198
+ if (toolMsg) {
199
+ toolMsg.toolOutput = typeof r.content === 'string'
200
+ ? r.content : JSON.stringify(r.content, null, 2);
201
+ toolMsg.hasResult = true;
202
+ }
203
+ }
204
+ }
205
+
206
+ scrollToBottom();
207
+ }
208
+
209
+ // ── Message routing ───────────────────────────────
210
+
211
+ /**
212
+ * Handle incoming Loop-related messages from the WebSocket.
213
+ * Returns true if the message was consumed.
214
+ */
215
+ function handleLoopMessage(msg) {
216
+ switch (msg.type) {
217
+ case 'loops_list':
218
+ loopsList.value = msg.loops || [];
219
+ return true;
220
+
221
+ case 'loop_created':
222
+ loopsList.value.push(msg.loop);
223
+ loopError.value = '';
224
+ return true;
225
+
226
+ case 'loop_updated': {
227
+ const idx = loopsList.value.findIndex(l => l.id === msg.loop.id);
228
+ if (idx >= 0) loopsList.value[idx] = msg.loop;
229
+ if (selectedLoop.value?.id === msg.loop.id) {
230
+ selectedLoop.value = { ...msg.loop };
231
+ }
232
+ editingLoopId.value = null;
233
+ loopError.value = '';
234
+ return true;
235
+ }
236
+
237
+ case 'loop_deleted':
238
+ loopsList.value = loopsList.value.filter(l => l.id !== msg.loopId);
239
+ if (selectedLoop.value?.id === msg.loopId) backToLoopsList();
240
+ return true;
241
+
242
+ case 'loop_execution_started':
243
+ runningLoops.value = { ...runningLoops.value, [msg.loopId]: msg.execution };
244
+ // If viewing this loop's detail, prepend to history
245
+ if (selectedLoop.value?.id === msg.loopId) {
246
+ executionHistory.value.unshift(msg.execution);
247
+ }
248
+ return true;
249
+
250
+ case 'loop_execution_output':
251
+ // If user is viewing this execution live, append to display
252
+ if (selectedExecution.value === msg.executionId) {
253
+ appendOutputToDisplay(msg.data);
254
+ }
255
+ return true;
256
+
257
+ case 'loop_execution_completed': {
258
+ const newRunning = { ...runningLoops.value };
259
+ delete newRunning[msg.loopId];
260
+ runningLoops.value = newRunning;
261
+ // Update execution in history list
262
+ if (selectedLoop.value?.id === msg.loopId) {
263
+ const idx = executionHistory.value.findIndex(e => e.id === msg.execution.id);
264
+ if (idx >= 0) executionHistory.value[idx] = msg.execution;
265
+ }
266
+ // Finalize streaming message
267
+ const msgs = executionMessages.value;
268
+ if (msgs.length > 0) {
269
+ const last = msgs[msgs.length - 1];
270
+ if (last.role === 'assistant' && last.isStreaming) {
271
+ last.isStreaming = false;
272
+ }
273
+ }
274
+ // Update Loop's lastExecution in sidebar list
275
+ const loop = loopsList.value.find(l => l.id === msg.loopId);
276
+ if (loop) {
277
+ loop.lastExecution = {
278
+ id: msg.execution.id,
279
+ status: msg.execution.status,
280
+ startedAt: msg.execution.startedAt,
281
+ durationMs: msg.execution.durationMs,
282
+ trigger: msg.execution.trigger,
283
+ };
284
+ }
285
+ return true;
286
+ }
287
+
288
+ case 'loop_executions_list':
289
+ if (selectedLoop.value?.id === msg.loopId) {
290
+ const execs = msg.executions || [];
291
+ executionHistory.value = execs;
292
+ loadingExecutions.value = false;
293
+ loadingMoreExecutions.value = false;
294
+ hasMoreExecutions.value = execs.length >= execPageLimit;
295
+ }
296
+ return true;
297
+
298
+ case 'loop_execution_messages':
299
+ if (selectedExecution.value === msg.executionId) {
300
+ if (msg.messages && msg.messages.length > 0) {
301
+ let idCounter = 0;
302
+ executionMessages.value = buildHistoryBatch(msg.messages, () => ++idCounter);
303
+ liveMsgIdCounter = idCounter;
304
+ } else {
305
+ executionMessages.value = [];
306
+ }
307
+ loadingExecution.value = false;
308
+ scrollToBottom();
309
+ }
310
+ return true;
311
+
312
+ default:
313
+ return false;
314
+ }
315
+ }
316
+
317
+ return {
318
+ // State
319
+ loopsList, selectedLoop, selectedExecution,
320
+ executionHistory, executionMessages, runningLoops,
321
+ loadingExecutions, loadingExecution, editingLoopId,
322
+ loopError, hasMoreExecutions, loadingMoreExecutions,
323
+ // Computed
324
+ hasRunningLoop, firstRunningLoop,
325
+ // CRUD
326
+ createNewLoop, updateExistingLoop, deleteExistingLoop,
327
+ toggleLoop, runNow, cancelExecution, requestLoopsList,
328
+ // Navigation
329
+ viewLoopDetail, viewExecution,
330
+ backToLoopsList, backToLoopDetail,
331
+ startEditing, cancelEditing,
332
+ // Pagination & errors
333
+ loadMoreExecutions, clearLoopError,
334
+ // Message routing
335
+ handleLoopMessage,
336
+ };
337
+ }