@agent-link/server 0.1.184 → 0.1.186

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-link/server",
3
- "version": "0.1.184",
3
+ "version": "0.1.186",
4
4
  "description": "AgentLink relay server",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -127,6 +127,10 @@ const App = {
127
127
  const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
128
128
  const processingConversations = ref({}); // conversationId → boolean
129
129
 
130
+ // Plan mode state
131
+ const planMode = ref(false);
132
+ const pendingPlanMode = ref(null); // 'enter' | 'exit' | null — set while toggle is in flight
133
+
130
134
  // File browser state
131
135
  const filePanelOpen = ref(false);
132
136
  const filePanelWidth = ref(parseInt(localStorage.getItem('agentlink-file-panel-width'), 10) || 280);
@@ -240,6 +244,7 @@ const App = {
240
244
  messageIdCounter: streaming.getMessageIdCounter(),
241
245
  queuedMessages: queuedMessages.value,
242
246
  usageStats: usageStats.value,
247
+ planMode: planMode.value,
243
248
  };
244
249
  }
245
250
 
@@ -260,6 +265,7 @@ const App = {
260
265
  _restoreToolMsgMap(cached.toolMsgMap || new Map());
261
266
  queuedMessages.value = cached.queuedMessages || [];
262
267
  usageStats.value = cached.usageStats || null;
268
+ planMode.value = cached.planMode || false;
263
269
  } else {
264
270
  // New blank conversation
265
271
  messages.value = [];
@@ -275,6 +281,7 @@ const App = {
275
281
  _clearToolMsgMap();
276
282
  queuedMessages.value = [];
277
283
  usageStats.value = null;
284
+ planMode.value = false;
278
285
  }
279
286
 
280
287
  currentConversationId.value = newConvId;
@@ -364,6 +371,8 @@ const App = {
364
371
  memoryFiles, memoryDir, memoryLoading, memoryEditing, memoryEditContent, memorySaving, memoryPanelOpen,
365
372
  // Side question (/btw)
366
373
  btwState, btwPending,
374
+ // Plan mode
375
+ setPlanMode,
367
376
  // i18n
368
377
  t,
369
378
  });
@@ -406,8 +415,15 @@ const App = {
406
415
  });
407
416
  setFilePreview(filePreview);
408
417
 
409
- // Track mobile state on resize
410
- let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
418
+ // Track mobile state on resize (rAF-throttled)
419
+ let _resizeRafId = 0;
420
+ let _resizeHandler = () => {
421
+ if (_resizeRafId) return;
422
+ _resizeRafId = requestAnimationFrame(() => {
423
+ _resizeRafId = 0;
424
+ isMobile.value = window.innerWidth <= 768;
425
+ });
426
+ };
411
427
  window.addEventListener('resize', _resizeHandler);
412
428
 
413
429
  // Close workdir menu on outside click or Escape
@@ -426,10 +442,11 @@ const App = {
426
442
 
427
443
  // ── Computed ──
428
444
  const hasInput = computed(() => !!(inputText.value.trim() || attachments.value.length > 0));
445
+ const hasPendingQuestion = computed(() => messages.value.some(m => m.role === 'ask-question' && !m.answered));
429
446
  const canSend = computed(() =>
430
- status.value === 'Connected' && hasInput.value && !isCompacting.value
431
- && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
447
+ status.value === 'Connected' && hasInput.value && !isCompacting.value && !hasPendingQuestion.value
432
448
  );
449
+ const hasStreamingMessage = computed(() => messages.value.some(m => m.isStreaming));
433
450
 
434
451
  // ── Slash command menu ──
435
452
  const slashMenuVisible = computed(() => {
@@ -558,6 +575,28 @@ const App = {
558
575
  if (idx !== -1) queuedMessages.value.splice(idx, 1);
559
576
  }
560
577
 
578
+ // ── Plan mode ──
579
+ function togglePlanMode() {
580
+ if (isProcessing.value) return;
581
+ const newMode = !planMode.value;
582
+ pendingPlanMode.value = newMode ? 'enter' : 'exit';
583
+ isProcessing.value = true;
584
+ if (currentConversationId.value) {
585
+ processingConversations.value[currentConversationId.value] = true;
586
+ }
587
+ const instruction = newMode ? 'Enter plan mode now.' : 'Exit plan mode now.';
588
+ messages.value.push({
589
+ id: streaming.nextId(), role: 'user', content: instruction,
590
+ status: 'sent', timestamp: new Date(),
591
+ });
592
+ wsSend({ type: 'set_plan_mode', enabled: newMode, conversationId: currentConversationId.value, claudeSessionId: currentClaudeSessionId.value });
593
+ nextTick(() => scrollToBottom());
594
+ }
595
+ function setPlanMode(enabled) {
596
+ planMode.value = enabled;
597
+ pendingPlanMode.value = null;
598
+ }
599
+
561
600
  function selectSlashCommand(cmd) {
562
601
  slashMenuOpen.value = false;
563
602
  if (cmd.isPrefix) {
@@ -695,9 +734,11 @@ const App = {
695
734
  status, agentName, hostname, workDir, sessionId, error,
696
735
  serverVersion, agentVersion, latency,
697
736
  messages, visibleMessages, hasMoreMessages, loadMoreMessages,
698
- inputText, isProcessing, isCompacting, canSend, hasInput, inputRef, queuedMessages, usageStats,
737
+ inputText, isProcessing, isCompacting, canSend, hasInput, hasStreamingMessage, inputRef, queuedMessages, usageStats,
699
738
  slashMenuVisible, filteredSlashCommands, slashMenuIndex, slashMenuOpen, selectSlashCommand, openSlashMenu,
700
739
  sendMessage, handleKeydown, cancelExecution, removeQueuedMessage, onMessageListScroll,
740
+ // Plan mode
741
+ planMode, pendingPlanMode, togglePlanMode,
701
742
  // Side question (/btw)
702
743
  btwState, btwPending, dismissBtw, renderMarkdown,
703
744
  getRenderedContent, copyMessage, toggleTool,
@@ -2021,6 +2062,12 @@ const App = {
2021
2062
  <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
2022
2063
  </div>
2023
2064
  </div>
2065
+ <!-- Plan mode switch indicator -->
2066
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2067
+ <span class="plan-mode-divider-line"></span>
2068
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2069
+ <span class="plan-mode-divider-line"></span>
2070
+ </div>
2024
2071
  <!-- Agent tool use -->
2025
2072
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2026
2073
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
@@ -2033,7 +2080,7 @@ const App = {
2033
2080
  </span>
2034
2081
  <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
2035
2082
  </div>
2036
- <div v-show="msg.expanded" class="tool-expand">
2083
+ <div v-if="msg.expanded" class="tool-expand">
2037
2084
  <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
2038
2085
  <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
2039
2086
  <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
@@ -2079,6 +2126,11 @@ const App = {
2079
2126
  <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
2080
2127
  </div>
2081
2128
  </div>
2129
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2130
+ <span class="plan-mode-divider-line"></span>
2131
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2132
+ <span class="plan-mode-divider-line"></span>
2133
+ </div>
2082
2134
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2083
2135
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
2084
2136
  <span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
@@ -2090,7 +2142,7 @@ const App = {
2090
2142
  </span>
2091
2143
  <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
2092
2144
  </div>
2093
- <div v-show="msg.expanded" class="tool-expand">
2145
+ <div v-if="msg.expanded" class="tool-expand">
2094
2146
  <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
2095
2147
  <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
2096
2148
  <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
@@ -2394,7 +2446,7 @@ const App = {
2394
2446
  </span>
2395
2447
  <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
2396
2448
  </div>
2397
- <div v-show="msg.expanded" class="tool-expand team-agent-tool-expand">
2449
+ <div v-if="msg.expanded" class="tool-expand team-agent-tool-expand">
2398
2450
  <pre v-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
2399
2451
  <div v-if="msg.toolOutput" class="team-agent-tool-result">
2400
2452
  <div class="team-agent-tool-result-label">{{ t('team.agentResult') }}</div>
@@ -2403,6 +2455,13 @@ const App = {
2403
2455
  </div>
2404
2456
  </div>
2405
2457
 
2458
+ <!-- Plan mode switch indicator -->
2459
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2460
+ <span class="plan-mode-divider-line"></span>
2461
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2462
+ <span class="plan-mode-divider-line"></span>
2463
+ </div>
2464
+
2406
2465
  <!-- Tool use block (collapsible) -->
2407
2466
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2408
2467
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
@@ -2415,7 +2474,7 @@ const App = {
2415
2474
  </span>
2416
2475
  <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
2417
2476
  </div>
2418
- <div v-show="msg.expanded" class="tool-expand">
2477
+ <div v-if="msg.expanded" class="tool-expand">
2419
2478
  <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
2420
2479
  <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
2421
2480
  <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
@@ -2490,8 +2549,9 @@ const App = {
2490
2549
  </div>
2491
2550
  </div>
2492
2551
 
2493
- <div v-if="isProcessing && !messages.some(m => m.isStreaming)" class="typing-indicator">
2552
+ <div v-if="isProcessing && !hasStreamingMessage" class="typing-indicator">
2494
2553
  <span></span><span></span><span></span>
2554
+ <span v-if="pendingPlanMode" class="typing-label">{{ pendingPlanMode === 'enter' ? t('tool.enteringPlanMode') : t('tool.exitingPlanMode') }}</span>
2495
2555
  </div>
2496
2556
  </div>
2497
2557
  </div>
@@ -2555,7 +2615,7 @@ const App = {
2555
2615
  </div>
2556
2616
  </div>
2557
2617
  <div
2558
- :class="['input-card', { 'drag-over': dragOver }]"
2618
+ :class="['input-card', { 'drag-over': dragOver, 'plan-mode': planMode }]"
2559
2619
  @dragover="handleDragOver"
2560
2620
  @dragleave="handleDragLeave"
2561
2621
  @drop="handleDrop"
@@ -2591,6 +2651,10 @@ const App = {
2591
2651
  <button class="slash-btn" @click="openSlashMenu" :disabled="status !== 'Connected'" :title="t('input.slashCommands')">
2592
2652
  <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 21 11 3h2L9 21H7Z"/></svg>
2593
2653
  </button>
2654
+ <button :class="['plan-mode-btn', { active: planMode }]" @click="togglePlanMode" :disabled="isProcessing" :title="planMode ? 'Switch to Normal Mode' : 'Switch to Plan Mode'">
2655
+ <svg viewBox="0 0 24 24" width="12" height="12"><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></svg>
2656
+ Plan
2657
+ </button>
2594
2658
  </div>
2595
2659
  <button v-if="isProcessing && !hasInput" @click="cancelExecution" class="send-btn stop-btn" :title="t('input.stopGeneration')">
2596
2660
  <svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
@@ -271,7 +271,7 @@
271
271
  padding: 0.5rem 0.9rem;
272
272
  }
273
273
 
274
- .typing-indicator span {
274
+ .typing-indicator span:not(.typing-label) {
275
275
  width: 6px;
276
276
  height: 6px;
277
277
  border-radius: 50%;
@@ -279,11 +279,11 @@
279
279
  animation: typing 1.2s infinite ease-in-out;
280
280
  }
281
281
 
282
- .typing-indicator span:nth-child(2) {
282
+ .typing-indicator span:not(.typing-label):nth-child(2) {
283
283
  animation-delay: 0.2s;
284
284
  }
285
285
 
286
- .typing-indicator span:nth-child(3) {
286
+ .typing-indicator span:not(.typing-label):nth-child(3) {
287
287
  animation-delay: 0.4s;
288
288
  }
289
289
 
@@ -292,6 +292,18 @@
292
292
  30% { opacity: 1; transform: scale(1); }
293
293
  }
294
294
 
295
+ .typing-label {
296
+ font-size: 0.78rem;
297
+ color: var(--text-secondary);
298
+ margin-left: 4px;
299
+ white-space: nowrap;
300
+ animation: none;
301
+ width: auto;
302
+ height: auto;
303
+ border-radius: 0;
304
+ background: none;
305
+ }
306
+
295
307
  /* ── Context compaction inline message ── */
296
308
  .compact-msg {
297
309
  display: inline-flex;
package/web/css/base.css CHANGED
@@ -30,6 +30,8 @@
30
30
  --border: #353535;
31
31
  --code-bg: #1a1a1a;
32
32
  --code-header-bg: #222222;
33
+ --plan-mode: #d4a24c;
34
+ --plan-mode-bg: rgba(212, 162, 76, 0.1);
33
35
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
34
36
  }
35
37
 
@@ -48,6 +50,8 @@
48
50
  --border: #d1d5db;
49
51
  --code-bg: #f1f3f5;
50
52
  --code-header-bg: #e9ecef;
53
+ --plan-mode: #d97706;
54
+ --plan-mode-bg: rgba(217, 119, 6, 0.08);
51
55
  }
52
56
 
53
57
  html {
@@ -236,6 +240,25 @@ body {
236
240
  margin-top: 0.75rem;
237
241
  }
238
242
 
243
+ /* ── Shared keyframes ── */
244
+ @keyframes spin {
245
+ to { transform: rotate(360deg); }
246
+ }
247
+
248
+ @keyframes pulse {
249
+ 0%, 100% { opacity: 1; }
250
+ 50% { opacity: 0.3; }
251
+ }
252
+
253
+ /* ── Reduced motion ── */
254
+ @media (prefers-reduced-motion: reduce) {
255
+ *, *::before, *::after {
256
+ animation-duration: 0.01ms !important;
257
+ animation-iteration-count: 1 !important;
258
+ transition-duration: 0.01ms !important;
259
+ }
260
+ }
261
+
239
262
  /* ── Main body (sidebar + chat) ── */
240
263
  .main-body {
241
264
  flex: 1;
package/web/css/chat.css CHANGED
@@ -172,3 +172,5 @@
172
172
  padding: 0.2rem 0;
173
173
  }
174
174
 
175
+
176
+
@@ -151,10 +151,6 @@
151
151
  margin-left: auto;
152
152
  }
153
153
 
154
- @keyframes spin {
155
- to { transform: rotate(360deg); }
156
- }
157
-
158
154
  .file-tree-empty {
159
155
  padding: 4px 8px;
160
156
  font-size: 0.75rem;
package/web/css/input.css CHANGED
@@ -225,7 +225,6 @@
225
225
  align-items: center;
226
226
  justify-content: center;
227
227
  gap: 16px;
228
- backdrop-filter: blur(2px);
229
228
  }
230
229
  .workdir-switching-spinner {
231
230
  width: 36px;
@@ -233,10 +232,7 @@
233
232
  border: 3px solid rgba(255, 255, 255, 0.2);
234
233
  border-top-color: rgba(255, 255, 255, 0.8);
235
234
  border-radius: 50%;
236
- animation: workdir-spin 0.7s linear infinite;
237
- }
238
- @keyframes workdir-spin {
239
- to { transform: rotate(360deg); }
235
+ animation: spin 0.7s linear infinite;
240
236
  }
241
237
  .workdir-switching-text {
242
238
  color: rgba(255, 255, 255, 0.9);
@@ -618,6 +614,56 @@
618
614
  color: var(--text-secondary);
619
615
  }
620
616
 
617
+ /* ── Plan Mode toggle button ── */
618
+ .plan-mode-btn {
619
+ background: none;
620
+ border: 1px solid var(--border);
621
+ color: var(--text-secondary);
622
+ cursor: pointer;
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 4px;
626
+ height: 28px;
627
+ padding: 0 8px;
628
+ border-radius: 6px;
629
+ font-size: 0.75rem;
630
+ font-weight: 600;
631
+ letter-spacing: 0.02em;
632
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
633
+ white-space: nowrap;
634
+ }
635
+
636
+ .plan-mode-btn:hover:not(:disabled) {
637
+ color: var(--text-primary);
638
+ background: var(--bg-tertiary);
639
+ border-color: var(--text-secondary);
640
+ }
641
+
642
+ .plan-mode-btn.active {
643
+ color: var(--plan-mode);
644
+ background: var(--plan-mode-bg);
645
+ border-color: var(--plan-mode);
646
+ }
647
+
648
+ .plan-mode-btn.active:hover:not(:disabled) {
649
+ background: rgba(212, 162, 76, 0.18);
650
+ }
651
+
652
+ .plan-mode-btn:disabled {
653
+ opacity: 0.35;
654
+ cursor: not-allowed;
655
+ }
656
+
657
+ .plan-mode-btn svg {
658
+ flex-shrink: 0;
659
+ }
660
+
661
+
662
+ /* ── Input card plan mode accent ── */
663
+ .input-card.plan-mode {
664
+ border-top: 2px solid var(--plan-mode);
665
+ }
666
+
621
667
  /* ── Sidebar backdrop (mobile overlay) ── */
622
668
  .sidebar-backdrop {
623
669
  display: none;
package/web/css/loop.css CHANGED
@@ -185,7 +185,7 @@
185
185
  border: 1px solid var(--border);
186
186
  border-radius: 6px;
187
187
  cursor: pointer;
188
- transition: all 0.15s;
188
+ transition: color 0.15s, border-color 0.15s;
189
189
  white-space: nowrap;
190
190
  }
191
191
  .loop-action-btn:hover:not(:disabled) {
@@ -353,11 +353,7 @@
353
353
  }
354
354
  .loop-exec-status-running {
355
355
  color: var(--accent);
356
- animation: loop-spin 1s linear infinite;
357
- }
358
- @keyframes loop-spin {
359
- from { transform: rotate(0deg); }
360
- to { transform: rotate(360deg); }
356
+ animation: spin 1s linear infinite;
361
357
  }
362
358
  .loop-exec-status-success {
363
359
  color: #10B981;
@@ -428,11 +424,7 @@
428
424
  height: 8px;
429
425
  border-radius: 50%;
430
426
  background: var(--accent);
431
- animation: loop-pulse 1.5s ease-in-out infinite;
432
- }
433
- @keyframes loop-pulse {
434
- 0%, 100% { opacity: 1; }
435
- 50% { opacity: 0.3; }
427
+ animation: pulse 1.5s ease-in-out infinite;
436
428
  }
437
429
 
438
430
  /* ── Modal dialog (generic) ── */
@@ -210,11 +210,6 @@
210
210
  transform: rotate(-90deg);
211
211
  }
212
212
 
213
- @keyframes spin {
214
- from { transform: rotate(0deg); }
215
- to { transform: rotate(360deg); }
216
- }
217
-
218
213
  .spinning {
219
214
  animation: spin 0.8s linear infinite;
220
215
  }
@@ -329,12 +324,7 @@
329
324
  background: var(--accent);
330
325
  margin-right: 6px;
331
326
  vertical-align: middle;
332
- animation: pulse-dot 1.5s ease-in-out infinite;
333
- }
334
-
335
- @keyframes pulse-dot {
336
- 0%, 100% { opacity: 1; }
337
- 50% { opacity: 0.3; }
327
+ animation: pulse 1.5s ease-in-out infinite;
338
328
  }
339
329
 
340
330
  .session-meta {
package/web/css/team.css CHANGED
@@ -21,7 +21,7 @@
21
21
  font-size: 0.75rem;
22
22
  font-weight: 500;
23
23
  cursor: pointer;
24
- transition: all 0.15s;
24
+ transition: color 0.15s, background 0.15s, box-shadow 0.15s;
25
25
  }
26
26
 
27
27
  .team-mode-btn.active {
@@ -194,7 +194,7 @@
194
194
  color: var(--text-secondary);
195
195
  font-size: 0.78rem;
196
196
  cursor: pointer;
197
- transition: all 0.15s;
197
+ transition: color 0.15s, border-color 0.15s;
198
198
  }
199
199
  .team-lead-prompt-reset:hover {
200
200
  color: var(--text-primary);
@@ -258,7 +258,7 @@
258
258
  color: var(--text-secondary);
259
259
  font-size: 0.85rem;
260
260
  cursor: pointer;
261
- transition: all 0.15s;
261
+ transition: color 0.15s, border-color 0.15s;
262
262
  }
263
263
 
264
264
  .team-create-cancel:hover {
@@ -293,7 +293,7 @@
293
293
  padding: 14px 16px;
294
294
  border: 1px solid var(--border);
295
295
  border-radius: 10px;
296
- transition: all 0.15s;
296
+ transition: border-color 0.15s, background 0.15s;
297
297
  background: linear-gradient(135deg, rgba(255,255,255,0.02) 0%, transparent 100%);
298
298
  }
299
299
 
@@ -354,7 +354,7 @@
354
354
  border: 1px solid var(--accent);
355
355
  border-radius: 6px;
356
356
  cursor: pointer;
357
- transition: all 0.15s;
357
+ transition: color 0.15s, background 0.15s;
358
358
  white-space: nowrap;
359
359
  }
360
360
 
@@ -500,7 +500,7 @@
500
500
  color: var(--error);
501
501
  font-size: 0.8rem;
502
502
  cursor: pointer;
503
- transition: all 0.15s;
503
+ transition: background 0.15s;
504
504
  }
505
505
 
506
506
  .team-dissolve-btn:hover {
@@ -515,7 +515,7 @@
515
515
  color: var(--text-secondary);
516
516
  font-size: 0.8rem;
517
517
  cursor: pointer;
518
- transition: all 0.15s;
518
+ transition: color 0.15s, border-color 0.15s;
519
519
  }
520
520
 
521
521
  .team-back-btn:hover {
@@ -531,7 +531,7 @@
531
531
  color: var(--accent);
532
532
  font-size: 0.8rem;
533
533
  cursor: pointer;
534
- transition: all 0.15s;
534
+ transition: background 0.15s;
535
535
  }
536
536
 
537
537
  .team-new-btn:hover {
@@ -1111,7 +1111,7 @@
1111
1111
  color: var(--text-secondary);
1112
1112
  font-size: 0.78rem;
1113
1113
  cursor: pointer;
1114
- transition: all 0.15s;
1114
+ transition: color 0.15s, border-color 0.15s;
1115
1115
  margin-right: 4px;
1116
1116
  }
1117
1117
 
package/web/css/tools.css CHANGED
@@ -97,8 +97,8 @@
97
97
  }
98
98
 
99
99
  @keyframes toolExpand {
100
- from { opacity: 0; max-height: 0; }
101
- to { opacity: 1; max-height: 500px; }
100
+ from { opacity: 0; }
101
+ to { opacity: 1; }
102
102
  }
103
103
 
104
104
  .tool-block {
@@ -304,3 +304,24 @@
304
304
  margin: 0.75rem 0;
305
305
  }
306
306
 
307
+ /* ── Plan mode divider ── */
308
+ .plan-mode-divider {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 12px;
312
+ margin: 8px 0;
313
+ }
314
+
315
+ .plan-mode-divider-line {
316
+ flex: 1;
317
+ height: 1px;
318
+ background: var(--border);
319
+ }
320
+
321
+ .plan-mode-divider-text {
322
+ font-size: 0.75rem;
323
+ color: var(--text-secondary);
324
+ white-space: nowrap;
325
+ font-weight: 500;
326
+ }
327
+
@@ -245,6 +245,10 @@
245
245
  "tool.inPath": "in {path}",
246
246
  "tool.done": "{done}/{total} done",
247
247
  "tool.replaceAll": "(replace all)",
248
+ "tool.enteredPlanMode": "Entered Plan Mode",
249
+ "tool.exitedPlanMode": "Exited Plan Mode",
250
+ "tool.enteringPlanMode": "Entering Plan Mode...",
251
+ "tool.exitingPlanMode": "Exiting Plan Mode...",
248
252
 
249
253
  "usage.context": "Context",
250
254
  "usage.cost": "Cost",
@@ -245,6 +245,10 @@
245
245
  "tool.inPath": "在 {path}",
246
246
  "tool.done": "{done}/{total} 已完成",
247
247
  "tool.replaceAll": "(全部替换)",
248
+ "tool.enteredPlanMode": "已进入计划模式",
249
+ "tool.exitedPlanMode": "已退出计划模式",
250
+ "tool.enteringPlanMode": "正在进入计划模式...",
251
+ "tool.exitingPlanMode": "正在退出计划模式...",
248
252
 
249
253
  "usage.context": "上下文",
250
254
  "usage.cost": "费用",
@@ -44,7 +44,8 @@ export function createHighlightScheduler() {
44
44
  _hlTimer = setTimeout(() => {
45
45
  _hlTimer = null;
46
46
  if (typeof hljs !== 'undefined') {
47
- document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
47
+ const root = document.querySelector('.message-list') || document;
48
+ root.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
48
49
  hljs.highlightElement(block);
49
50
  block.dataset.highlighted = 'true';
50
51
  });
@@ -1,6 +1,13 @@
1
1
  // ── History batch building & background conversation routing ──────────────────
2
2
  import { isContextSummary } from './messageHelpers.js';
3
3
 
4
+ function findLast(arr, predicate) {
5
+ for (let i = arr.length - 1; i >= 0; i--) {
6
+ if (predicate(arr[i])) return arr[i];
7
+ }
8
+ return undefined;
9
+ }
10
+
4
11
  /**
5
12
  * Convert a history array (from conversation_resumed) into a batch of UI messages.
6
13
  * @param {Array} history - Array of {role, content, ...} from the agent
@@ -133,11 +140,14 @@ export function routeToBackgroundConversation(deps, convId, msg) {
133
140
  const msgs = cache.messages;
134
141
  const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
135
142
  if (last && last.role === 'assistant' && last.isStreaming) {
136
- last.content += data.delta;
143
+ if (!last._chunks) last._chunks = [last.content];
144
+ last._chunks.push(data.delta);
145
+ last.content = last._chunks.join('');
137
146
  } else {
138
147
  msgs.push({
139
148
  id: ++cache.messageIdCounter, role: 'assistant',
140
149
  content: data.delta, isStreaming: true, timestamp: new Date(),
150
+ _chunks: [data.delta],
141
151
  });
142
152
  }
143
153
  } else if (data.type === 'tool_use' && data.tools) {
@@ -206,7 +216,7 @@ export function routeToBackgroundConversation(deps, convId, msg) {
206
216
  });
207
217
  } else if (msg.status === 'completed') {
208
218
  cache.isCompacting = false;
209
- const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
219
+ const startMsg = findLast(cache.messages, m => m.isCompactStart && !m.compactDone);
210
220
  if (startMsg) {
211
221
  startMsg.content = 'Context compacted';
212
222
  startMsg.compactDone = true;
@@ -7,6 +7,13 @@ const MAX_RECONNECT_ATTEMPTS = 50;
7
7
  const RECONNECT_BASE_DELAY = 1000;
8
8
  const RECONNECT_MAX_DELAY = 15000;
9
9
 
10
+ function findLast(arr, predicate) {
11
+ for (let i = arr.length - 1; i >= 0; i--) {
12
+ if (predicate(arr[i])) return arr[i];
13
+ }
14
+ return undefined;
15
+ }
16
+
10
17
  /**
11
18
  * Creates the WebSocket connection controller.
12
19
  * @param {object} deps - All reactive state and callbacks needed
@@ -29,6 +36,8 @@ export function createConnection(deps) {
29
36
  memoryFiles, memoryDir, memoryLoading, memoryEditing, memoryEditContent, memorySaving, memoryPanelOpen,
30
37
  // Side question (/btw)
31
38
  btwState, btwPending,
39
+ // Plan mode
40
+ setPlanMode,
32
41
  // i18n
33
42
  t,
34
43
  } = deps;
@@ -469,7 +478,7 @@ export function createConnection(deps) {
469
478
  } else if (msg.status === 'completed') {
470
479
  isCompacting.value = false;
471
480
  // Update the start message to show completed
472
- const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
481
+ const startMsg = findLast(messages.value, m => m.isCompactStart && !m.compactDone);
473
482
  if (startMsg) {
474
483
  startMsg.content = t('system.contextCompacted');
475
484
  startMsg.compactDone = true;
@@ -540,6 +549,10 @@ export function createConnection(deps) {
540
549
  messages.value = buildHistoryBatch(msg.history, () => streaming.nextId());
541
550
  toolMsgMap.clear();
542
551
  }
552
+ // Detect plan mode from agent-provided flag
553
+ if (msg.planMode != null) {
554
+ if (setPlanMode) setPlanMode(!!msg.planMode);
555
+ }
543
556
  loadingHistory.value = false;
544
557
  // Restore live status from agent (compacting / processing)
545
558
  if (msg.isCompacting) {
@@ -604,6 +617,17 @@ export function createConnection(deps) {
604
617
  btwState.value.done = true;
605
618
  }
606
619
  }
620
+ } else if (msg.type === 'plan_mode_changed') {
621
+ if (setPlanMode) setPlanMode(msg.enabled);
622
+ // For the immediate path (no injected turn), clear isProcessing here
623
+ // because turn_completed will never arrive.
624
+ if (msg.immediate) {
625
+ isProcessing.value = false;
626
+ if (currentConversationId.value) {
627
+ processingConversations.value[currentConversationId.value] = false;
628
+ }
629
+ }
630
+ // For the injected path, turn_completed handles isProcessing naturally.
607
631
  } else if (msg.type === 'workdir_changed') {
608
632
  workdirSwitching.value = false;
609
633
  workDir.value = msg.workDir;
@@ -4,6 +4,13 @@ import { renderMarkdown } from './markdown.js';
4
4
  // ── Helpers ──────────────────────────────────────────────────────────────────
5
5
  const CONTEXT_SUMMARY_PREFIX = 'This session is being continued from a previous conversation';
6
6
 
7
+ function parseToolInput(msg) {
8
+ if (msg._parsedInput !== undefined) return msg._parsedInput;
9
+ try { msg._parsedInput = JSON.parse(msg.toolInput); }
10
+ catch { msg._parsedInput = null; }
11
+ return msg._parsedInput;
12
+ }
13
+
7
14
  export function isContextSummary(text) {
8
15
  return typeof text === 'string' && text.trimStart().startsWith(CONTEXT_SUMMARY_PREFIX);
9
16
  }
@@ -28,6 +35,10 @@ export function formatTimestamp(ts) {
28
35
 
29
36
  export function getRenderedContent(msg) {
30
37
  if (msg.role !== 'assistant' && !msg.isCommandOutput) return msg.content;
38
+ if (msg.isStreaming) {
39
+ const t = msg.content || '';
40
+ return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
41
+ }
31
42
  return renderMarkdown(msg.content);
32
43
  }
33
44
 
@@ -55,9 +66,9 @@ export function toggleTool(msg) {
55
66
 
56
67
  export function getToolSummary(msg, t) {
57
68
  const name = msg.toolName;
58
- const input = msg.toolInput;
69
+ const obj = parseToolInput(msg);
70
+ if (!obj) return '';
59
71
  try {
60
- const obj = JSON.parse(input);
61
72
  if (name === 'Read' && obj.file_path) return obj.file_path;
62
73
  if (name === 'Edit' && obj.file_path) return obj.file_path;
63
74
  if (name === 'Write' && obj.file_path) return obj.file_path;
@@ -83,8 +94,9 @@ export function isEditTool(msg) {
83
94
 
84
95
  export function getFormattedToolInput(msg, t) {
85
96
  if (!msg.toolInput) return null;
97
+ const obj = parseToolInput(msg);
98
+ if (!obj) return null;
86
99
  try {
87
- const obj = JSON.parse(msg.toolInput);
88
100
  const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
89
101
  const name = msg.toolName;
90
102
 
@@ -169,8 +181,9 @@ export function getFormattedToolInput(msg, t) {
169
181
  }
170
182
 
171
183
  export function getEditDiffHtml(msg, t) {
184
+ const obj = parseToolInput(msg);
185
+ if (!obj) return null;
172
186
  try {
173
- const obj = JSON.parse(msg.toolInput);
174
187
  if (!obj.old_string && !obj.new_string) return null;
175
188
  const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
176
189
  const filePath = obj.file_path || '';
@@ -34,19 +34,21 @@ export function createStreaming({ messages, scrollToBottom }) {
34
34
  ? messages.value.find(m => m.id === streamingMessageId)
35
35
  : null;
36
36
 
37
+ const chunk = pendingText.slice(0, CHARS_PER_TICK);
38
+ pendingText = pendingText.slice(CHARS_PER_TICK);
39
+
37
40
  if (!streamMsg) {
38
41
  const id = ++messageIdCounter;
39
- const chunk = pendingText.slice(0, CHARS_PER_TICK);
40
- pendingText = pendingText.slice(CHARS_PER_TICK);
41
- messages.value.push({
42
+ const newMsg = {
42
43
  id, role: 'assistant', content: chunk,
43
44
  isStreaming: true, timestamp: new Date(),
44
- });
45
+ _chunks: [chunk],
46
+ };
47
+ messages.value.push(newMsg);
45
48
  streamingMessageId = id;
46
49
  } else {
47
- const chunk = pendingText.slice(0, CHARS_PER_TICK);
48
- pendingText = pendingText.slice(CHARS_PER_TICK);
49
- streamMsg.content += chunk;
50
+ streamMsg._chunks.push(chunk);
51
+ streamMsg.content = streamMsg._chunks.join('');
50
52
  }
51
53
  scrollToBottom();
52
54
  if (pendingText) revealTimer = setTimeout(revealTick, TICK_MS);
@@ -58,13 +60,17 @@ export function createStreaming({ messages, scrollToBottom }) {
58
60
  const streamMsg = streamingMessageId !== null
59
61
  ? messages.value.find(m => m.id === streamingMessageId) : null;
60
62
  if (streamMsg) {
61
- streamMsg.content += pendingText;
63
+ if (!streamMsg._chunks) streamMsg._chunks = [streamMsg.content];
64
+ streamMsg._chunks.push(pendingText);
65
+ streamMsg.content = streamMsg._chunks.join('');
62
66
  } else {
63
67
  const id = ++messageIdCounter;
64
- messages.value.push({
68
+ const newMsg = {
65
69
  id, role: 'assistant', content: pendingText,
66
70
  isStreaming: true, timestamp: new Date(),
67
- });
71
+ _chunks: [pendingText],
72
+ };
73
+ messages.value.push(newMsg);
68
74
  streamingMessageId = id;
69
75
  }
70
76
  pendingText = '';