openclacky 1.2.6 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +7 -1
  6. data/lib/clacky/agent/message_compressor.rb +2 -1
  7. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  8. data/lib/clacky/agent/session_serializer.rb +23 -4
  9. data/lib/clacky/agent.rb +46 -2
  10. data/lib/clacky/agent_config.rb +54 -6
  11. data/lib/clacky/billing/billing_store.rb +107 -3
  12. data/lib/clacky/brand_config.rb +0 -6
  13. data/lib/clacky/cli.rb +107 -1
  14. data/lib/clacky/client.rb +56 -6
  15. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  17. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  18. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  19. data/lib/clacky/json_ui_controller.rb +5 -2
  20. data/lib/clacky/patch_loader.rb +282 -0
  21. data/lib/clacky/plain_ui_controller.rb +1 -1
  22. data/lib/clacky/providers.rb +11 -2
  23. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +149 -13
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  26. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  27. data/lib/clacky/server/channel.rb +5 -0
  28. data/lib/clacky/server/http_server.rb +135 -14
  29. data/lib/clacky/server/scheduler.rb +1 -4
  30. data/lib/clacky/server/session_registry.rb +30 -4
  31. data/lib/clacky/server/web_ui_controller.rb +6 -3
  32. data/lib/clacky/shell_hook_loader.rb +181 -0
  33. data/lib/clacky/tools/terminal.rb +22 -26
  34. data/lib/clacky/ui2/ui_controller.rb +1 -1
  35. data/lib/clacky/ui_interface.rb +1 -1
  36. data/lib/clacky/version.rb +1 -1
  37. data/lib/clacky/web/app.css +392 -14
  38. data/lib/clacky/web/app.js +0 -1
  39. data/lib/clacky/web/billing.js +117 -22
  40. data/lib/clacky/web/i18n.js +50 -6
  41. data/lib/clacky/web/index.html +33 -0
  42. data/lib/clacky/web/sessions.js +203 -14
  43. data/lib/clacky/web/settings.js +59 -17
  44. data/lib/clacky/web/workspace.js +204 -0
  45. data/lib/clacky/web/ws-dispatcher.js +19 -3
  46. data/lib/clacky.rb +15 -0
  47. metadata +7 -2
@@ -4,6 +4,7 @@
4
4
  const Billing = (() => {
5
5
  let _summary = null;
6
6
  let _daily = [];
7
+ let _sessions = []; // 会话列表
7
8
  let _allModels = []; // 保存完整的模型列表
8
9
  let _currentPeriod = "day";
9
10
  let _currentModel = "all";
@@ -73,15 +74,19 @@ const Billing = (() => {
73
74
 
74
75
  try {
75
76
  const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
76
- const [summaryRes, dailyRes] = await Promise.all([
77
+ const [summaryRes, dailyRes, sessionsRes] = await Promise.all([
77
78
  fetch(`/api/billing/summary?period=${_currentPeriod}${modelParam}`),
78
- fetch(`/api/billing/daily?days=30${modelParam}`)
79
+ fetch(`/api/billing/daily?days=30${modelParam}`),
80
+ fetch(`/api/billing/sessions?period=${_currentPeriod}${modelParam}&limit=100`)
79
81
  ]);
80
82
 
81
83
  _summary = await summaryRes.json();
82
84
  const dailyData = await dailyRes.json();
83
85
  _daily = dailyData.days || [];
84
86
 
87
+ const sessionsData = await sessionsRes.json();
88
+ _sessions = sessionsData.sessions || [];
89
+
85
90
  // 保存完整模型列表(仅在未筛选时更新)
86
91
  if (!_currentModel || _currentModel === "all") {
87
92
  _allModels = _summary.by_model ? Object.keys(_summary.by_model) : [];
@@ -172,6 +177,10 @@ const Billing = (() => {
172
177
  <div class="billing-chart-row">
173
178
  ${_renderCombinedChart()}
174
179
  </div>
180
+
181
+ <div class="billing-sessions-row">
182
+ ${_renderSessionList()}
183
+ </div>
175
184
  </div>
176
185
  `;
177
186
 
@@ -219,14 +228,19 @@ const Billing = (() => {
219
228
  <span class="tooltip-date">${date}</span>
220
229
  <span class="tooltip-total">${total} tokens</span>
221
230
  </div>
231
+ <div class="tooltip-row">
232
+ <span class="tooltip-dot tooltip-total"></span>
233
+ <span class="tooltip-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
234
+ <span class="tooltip-value">${total}</span>
235
+ </div>
222
236
  <div class="tooltip-row">
223
237
  <span class="tooltip-dot tooltip-cache-hit"></span>
224
- <span class="tooltip-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
238
+ <span class="tooltip-label">${I18n.t("billing.inputCacheHit") || "Input (Hit)"}</span>
225
239
  <span class="tooltip-value">${cacheHit}</span>
226
240
  </div>
227
241
  <div class="tooltip-row">
228
242
  <span class="tooltip-dot tooltip-cache-miss"></span>
229
- <span class="tooltip-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
243
+ <span class="tooltip-label">${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}</span>
230
244
  <span class="tooltip-value">${cacheMiss}</span>
231
245
  </div>
232
246
  <div class="tooltip-row">
@@ -333,35 +347,50 @@ const Billing = (() => {
333
347
  }
334
348
 
335
349
  function _renderTokenBreakdown() {
350
+ const totalTokens = _summary.total_tokens || 0;
351
+ const promptTokens = _summary.prompt_tokens || 0;
352
+ const completionTokens = _summary.completion_tokens || 0;
353
+ const cacheReadTokens = _summary.cache_read_tokens || 0;
354
+ const cacheMissTokens = promptTokens - cacheReadTokens;
355
+
336
356
  return `
337
357
  <div class="billing-section billing-token-section">
338
358
  <h3>${I18n.t("billing.tokenBreakdown") || "Token Breakdown"}</h3>
339
359
  <div class="billing-token-bars">
340
360
  <div class="billing-token-bar-item">
341
361
  <div class="billing-token-bar-header">
342
- <span class="billing-token-bar-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
343
- <span class="billing-token-bar-value">${_formatCompact(_summary.cache_read_tokens)}</span>
362
+ <span class="billing-token-bar-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
363
+ <span class="billing-token-bar-value">${_formatCompact(totalTokens)}</span>
344
364
  </div>
345
365
  <div class="billing-token-bar-track">
346
- <div class="billing-token-bar-fill billing-bar-cache-read" style="width: ${_getTokenPercent('cache_read')}%"></div>
366
+ <div class="billing-token-bar-fill billing-bar-total" style="width: 100%"></div>
347
367
  </div>
348
368
  </div>
349
369
  <div class="billing-token-bar-item">
350
370
  <div class="billing-token-bar-header">
351
- <span class="billing-token-bar-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
352
- <span class="billing-token-bar-value">${_formatCompact(_summary.prompt_tokens)}</span>
371
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheHit") || "Input (Hit)"}</span>
372
+ <span class="billing-token-bar-value">${_formatCompact(cacheReadTokens)}</span>
353
373
  </div>
354
374
  <div class="billing-token-bar-track">
355
- <div class="billing-token-bar-fill billing-bar-prompt" style="width: ${_getTokenPercent('prompt')}%"></div>
375
+ <div class="billing-token-bar-fill billing-bar-cache-read" style="width: ${_getTokenPercent(cacheReadTokens, totalTokens)}%"></div>
376
+ </div>
377
+ </div>
378
+ <div class="billing-token-bar-item">
379
+ <div class="billing-token-bar-header">
380
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}</span>
381
+ <span class="billing-token-bar-value">${_formatCompact(cacheMissTokens)}</span>
382
+ </div>
383
+ <div class="billing-token-bar-track">
384
+ <div class="billing-token-bar-fill billing-bar-cache-miss" style="width: ${_getTokenPercent(cacheMissTokens, totalTokens)}%"></div>
356
385
  </div>
357
386
  </div>
358
387
  <div class="billing-token-bar-item">
359
388
  <div class="billing-token-bar-header">
360
389
  <span class="billing-token-bar-label">${I18n.t("billing.output") || "Output"}</span>
361
- <span class="billing-token-bar-value">${_formatCompact(_summary.completion_tokens)}</span>
390
+ <span class="billing-token-bar-value">${_formatCompact(completionTokens)}</span>
362
391
  </div>
363
392
  <div class="billing-token-bar-track">
364
- <div class="billing-token-bar-fill billing-bar-completion" style="width: ${_getTokenPercent('completion')}%"></div>
393
+ <div class="billing-token-bar-fill billing-bar-completion" style="width: ${_getTokenPercent(completionTokens, totalTokens)}%"></div>
365
394
  </div>
366
395
  </div>
367
396
  </div>
@@ -369,9 +398,8 @@ const Billing = (() => {
369
398
  `;
370
399
  }
371
400
 
372
- function _getTokenPercent(type) {
373
- const total = _summary.total_tokens || 1;
374
- const value = _summary[type + '_tokens'] || 0;
401
+ function _getTokenPercent(value, total) {
402
+ if (!total || total === 0) return 0;
375
403
  return Math.min((value / total) * 100, 100).toFixed(1);
376
404
  }
377
405
 
@@ -427,9 +455,8 @@ const Billing = (() => {
427
455
 
428
456
  const recentDays = _daily.slice(-14);
429
457
  // Max values for scaling
430
- const maxInput = Math.max(...recentDays.map(d => (d.prompt_tokens || 0) + (d.cache_read_tokens || 0)), 1);
431
- const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1);
432
- const maxVal = Math.max(maxInput, maxOutput);
458
+ const maxInput = Math.max(...recentDays.map(d => d.prompt_tokens || 0), 1);
459
+ const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1); const maxVal = Math.max(maxInput, maxOutput);
433
460
 
434
461
  // Chart height in pixels
435
462
  const chartHeight = 120;
@@ -437,9 +464,10 @@ const Billing = (() => {
437
464
  // Generate bars: each date has Input (stacked: cache hit + cache miss) and Output
438
465
  const chartBars = recentDays.map((d, i) => {
439
466
  const cacheHit = d.cache_read_tokens || 0; // 命中缓存
440
- const cacheMiss = d.prompt_tokens || 0; // 未命中缓存(实际发送的prompt)
467
+ const totalPrompt = d.prompt_tokens || 0; // 全部输入token
468
+ const cacheMiss = totalPrompt - cacheHit; // 未命中缓存 = 全部输入 - 命中
441
469
  const output = d.completion_tokens || 0;
442
- const totalInput = cacheHit + cacheMiss;
470
+ const totalInput = totalPrompt;
443
471
  const totalTokens = totalInput + output;
444
472
 
445
473
  // Calculate heights in pixels
@@ -471,13 +499,17 @@ const Billing = (() => {
471
499
  <div class="billing-chart-header">
472
500
  <h4>${I18n.t("billing.dailyUsage") || "Usage Details"}</h4>
473
501
  <div class="billing-chart-legends">
502
+ <span class="billing-chart-legend">
503
+ <span class="billing-legend-dot billing-legend-total"></span>
504
+ ${I18n.t("billing.totalTokens") || "Total Tokens"}
505
+ </span>
474
506
  <span class="billing-chart-legend">
475
507
  <span class="billing-legend-dot billing-legend-cache-hit"></span>
476
- ${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}
508
+ ${I18n.t("billing.inputCacheHit") || "Input (Hit)"}
477
509
  </span>
478
510
  <span class="billing-chart-legend">
479
511
  <span class="billing-legend-dot billing-legend-cache-miss"></span>
480
- ${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}
512
+ ${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}
481
513
  </span>
482
514
  <span class="billing-chart-legend">
483
515
  <span class="billing-legend-dot billing-legend-output"></span>
@@ -493,6 +525,69 @@ const Billing = (() => {
493
525
  `;
494
526
  }
495
527
 
528
+ function _renderSessionList() {
529
+ if (!_sessions || _sessions.length === 0) {
530
+ return `
531
+ <div class="billing-section billing-sessions-section">
532
+ <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
533
+ <div class="billing-sessions-empty">${I18n.t("billing.noSessions") || "No session data"}</div>
534
+ </div>
535
+ `;
536
+ }
537
+
538
+ const rows = _sessions.map((s, index) => {
539
+ const sessionId = s.session_id || "unknown";
540
+ const isDeleted = s.is_deleted;
541
+ const sessionName = s.session_name || sessionId;
542
+ const displayName = isDeleted ? (I18n.t("billing.deletedSessions") || "已删除会话") : (sessionName.length > 25 ? sessionName.slice(0, 25) + "..." : sessionName);
543
+ const totalCost = _convertCost(s.total_cost || 0);
544
+ const totalTokens = s.total_tokens || 0;
545
+ const promptTokens = s.prompt_tokens || 0;
546
+ const cacheHit = s.cache_read_tokens || 0;
547
+ const cacheMiss = promptTokens - cacheHit;
548
+ const completionTokens = s.completion_tokens || 0;
549
+ const requests = s.requests || 0;
550
+ const models = (s.models || []).join(", ");
551
+ const lastRequest = s.last_request ? new Date(s.last_request).toLocaleString() : "-";
552
+ const rowClass = isDeleted ? "billing-session-row billing-session-deleted" : "billing-session-row";
553
+
554
+ return `
555
+ <div class="${rowClass}" data-session-id="${_esc(sessionId)}">
556
+ <div class="billing-cell billing-cell-index">${index + 1}</div>
557
+ <div class="billing-cell billing-cell-session" title="${_esc(sessionName)}">
558
+ <span class="billing-cell-main">${_esc(displayName)}</span>
559
+ <span class="billing-cell-sub">${requests} ${I18n.t("billing.requests") || "req"} · ${_esc(models)}</span>
560
+ </div>
561
+ <div class="billing-cell billing-cell-number">${_formatCompact(totalTokens)}</div>
562
+ <div class="billing-cell billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)}</div>
563
+ <div class="billing-cell billing-cell-number billing-cell-miss">${_formatCompact(cacheMiss)}</div>
564
+ <div class="billing-cell billing-cell-number">${_formatCompact(completionTokens)}</div>
565
+ <div class="billing-cell billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</div>
566
+ <div class="billing-cell billing-cell-time">${lastRequest}</div>
567
+ </div>
568
+ `;
569
+ }).join("");
570
+
571
+ return `
572
+ <div class="billing-section billing-sessions-section">
573
+ <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
574
+ <div class="billing-sessions-header">
575
+ <span class="billing-cell billing-cell-index">#</span>
576
+ <span class="billing-cell billing-cell-session">${I18n.t("billing.sessionId") || "Session"}</span>
577
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerTotal") || "总消耗"}</span>
578
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerHit") || "命中"}</span>
579
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerMiss") || "未命中"}</span>
580
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerOutput") || "输出"}</span>
581
+ <span class="billing-cell billing-cell-cost">${I18n.t("billing.cost") || "Cost"}</span>
582
+ <span class="billing-cell billing-cell-time">${I18n.t("billing.lastRequest") || "Time"}</span>
583
+ </div>
584
+ <div class="billing-sessions-list">
585
+ ${rows}
586
+ </div>
587
+ </div>
588
+ `;
589
+ }
590
+
496
591
  // ── Helpers ─────────────────────────────────────────────────────────────────
497
592
 
498
593
  function _formatCost(cost) {
@@ -81,6 +81,14 @@ const I18n = (() => {
81
81
  "sessions.actions.downloadHint": "for debugging",
82
82
  "sib.dir.tooltip": "Click to change directory",
83
83
  "sib.dir.changePrompt": "Change working directory:",
84
+ "workspace.title": "Workspace",
85
+ "workspace.expand": "Open workspace",
86
+ "workspace.collapse": "Collapse workspace",
87
+ "workspace.refresh": "Refresh",
88
+ "workspace.empty": "This folder is empty",
89
+ "workspace.loading": "Loading…",
90
+ "workspace.error": "Failed to load files",
91
+ "workspace.downloadFailed": "Download failed",
84
92
  "sib.model.tooltip": "Click to switch model",
85
93
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
86
94
  "sib.signal.tooltip": "Recent LLM latency",
@@ -507,6 +515,7 @@ const I18n = (() => {
507
515
  "settings.models.btn.saved": "Saved ✓",
508
516
  "settings.models.btn.testing": "Testing…",
509
517
  "settings.models.btn.test": "Test",
518
+ "settings.models.link.topUp": "Top up / Manage account",
510
519
  "settings.models.btn.edit": "Edit",
511
520
  "settings.models.btn.delete": "Delete",
512
521
  "settings.models.btn.cancel": "Cancel",
@@ -677,12 +686,25 @@ const I18n = (() => {
677
686
  "billing.cacheHit": "Cache Hit",
678
687
  "billing.inputCacheHit": "Input (Cache Hit)",
679
688
  "billing.inputCacheMiss": "Input (Cache Miss)",
680
- "billing.output": "Output",
681
- "billing.tokenUsage": "Token Usage",
689
+ "billing.totalInput": "Total Input",
690
+ "billing.output": "Output", "billing.tokenUsage": "Token Usage",
682
691
  "billing.costTrend": "Cost Trend",
683
692
  "billing.noData": "No data available",
684
- },
685
693
 
694
+ "error.insufficient_credit": "Insufficient LLM credit. Please top up your account to continue.",
695
+ "error.insufficient_credit.action": "Top up",
696
+
697
+ "billing.sessions": "Sessions",
698
+ "billing.sessionId": "Session",
699
+ "billing.tokens": "Tokens",
700
+ "billing.lastRequest": "Last Request",
701
+ "billing.noSessions": "No session data",
702
+ "billing.deletedSessions": "Deleted Sessions",
703
+ "billing.headerTotal": "Total",
704
+ "billing.headerHit": "Hit",
705
+ "billing.headerMiss": "Miss",
706
+ "billing.headerOutput": "Output",
707
+ },
686
708
  zh: {
687
709
  // ── Sidebar ──
688
710
  "sidebar.chat": "会话",
@@ -750,6 +772,14 @@ const I18n = (() => {
750
772
  "sessions.actions.downloadHint": "用于调试",
751
773
  "sib.dir.tooltip": "点击切换工作目录",
752
774
  "sib.dir.changePrompt": "切换工作目录:",
775
+ "workspace.title": "工作区",
776
+ "workspace.expand": "打开工作区",
777
+ "workspace.collapse": "收起工作区",
778
+ "workspace.refresh": "刷新",
779
+ "workspace.empty": "此文件夹为空",
780
+ "workspace.loading": "加载中…",
781
+ "workspace.error": "加载文件失败",
782
+ "workspace.downloadFailed": "下载失败",
753
783
  "sib.model.tooltip": "点击切换模型",
754
784
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
755
785
  "sib.signal.tooltip": "最近一次 LLM 响应延迟",
@@ -1175,6 +1205,7 @@ const I18n = (() => {
1175
1205
  "settings.models.btn.saved": "已保存 ✓",
1176
1206
  "settings.models.btn.testing": "测试中…",
1177
1207
  "settings.models.btn.test": "测试",
1208
+ "settings.models.link.topUp": "充值 / 账户管理",
1178
1209
  "settings.models.btn.edit": "编辑",
1179
1210
  "settings.models.btn.delete": "删除",
1180
1211
  "settings.models.btn.cancel": "取消",
@@ -1345,13 +1376,26 @@ const I18n = (() => {
1345
1376
  "billing.cacheHit": "缓存命中",
1346
1377
  "billing.inputCacheHit": "输入(命中缓存)",
1347
1378
  "billing.inputCacheMiss": "输入(未命中缓存)",
1348
- "billing.output": "输出",
1349
- "billing.tokenUsage": "Token 用量",
1379
+ "billing.totalTokens": "Token 总消耗",
1380
+ "billing.totalInput": "总输入",
1381
+ "billing.output": "输出", "billing.tokenUsage": "Token 用量",
1350
1382
  "billing.costTrend": "费用趋势",
1351
1383
  "billing.noData": "暂无数据",
1384
+
1385
+ "error.insufficient_credit": "LLM(大模型)余额不足,请充值后继续使用。",
1386
+ "error.insufficient_credit.action": "去充值",
1387
+ "billing.sessions": "会话消耗",
1388
+ "billing.sessionId": "会话",
1389
+ "billing.tokens": "Token",
1390
+ "billing.lastRequest": "最后请求",
1391
+ "billing.noSessions": "暂无会话数据",
1392
+ "billing.deletedSessions": "已删除会话",
1393
+ "billing.headerTotal": "总消耗",
1394
+ "billing.headerHit": "命中",
1395
+ "billing.headerMiss": "未命中",
1396
+ "billing.headerOutput": "输出",
1352
1397
  }
1353
1398
  };
1354
-
1355
1399
  // ── State ──────────────────────────────────────────────────────────────────
1356
1400
  let _lang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
1357
1401
 
@@ -313,6 +313,8 @@
313
313
  <!-- Chat panel -->
314
314
  <div id="chat-panel" style="display:none">
315
315
 
316
+ <!-- Chat column (messages + info bar + input) -->
317
+ <div id="chat-main">
316
318
  <div id="messages"></div>
317
319
  <!-- New message notification banner -->
318
320
  <div id="new-message-banner" class="new-message-banner" style="display:none">
@@ -334,6 +336,7 @@
334
336
  <span id="sib-model-wrap">
335
337
  <span id="sib-model" class="sib-model-clickable" title="Click to switch model"></span>
336
338
  <div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
339
+ <div id="sib-submodel-panel" class="sib-submodel-panel" style="display:none"></div>
337
340
  </span>
338
341
  <span class="sib-sep sib-sep-after-model">│</span>
339
342
  <span id="sib-reasoning-wrap">
@@ -397,6 +400,35 @@
397
400
  <button id="btn-interrupt" style="display:none" title="Stop"></button>
398
401
  </div>
399
402
  </div>
403
+ </div><!-- /#chat-main -->
404
+
405
+ <!-- ── WORKSPACE PANEL (right) ──────────────────────────────────── -->
406
+ <aside id="workspace-panel" class="collapsed">
407
+ <div id="workspace-header">
408
+ <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
409
+ <div class="workspace-header-actions">
410
+ <button id="btn-workspace-refresh" class="workspace-icon-btn" data-i18n-title="workspace.refresh" title="Refresh">
411
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
412
+ <path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
413
+ <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"/>
414
+ </svg>
415
+ </button>
416
+ <button id="btn-workspace-close" class="workspace-icon-btn" data-i18n-title="workspace.collapse" title="Collapse">
417
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
418
+ <path d="M9 18l6-6-6-6"/>
419
+ </svg>
420
+ </button>
421
+ </div>
422
+ </div>
423
+ <div id="workspace-tree" role="tree"></div>
424
+ </aside>
425
+
426
+ <!-- Collapsed-state opener tab -->
427
+ <button id="btn-workspace-open" data-i18n-title="workspace.expand" title="Open workspace" style="display:none">
428
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
429
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
430
+ </svg>
431
+ </button>
400
432
  </div>
401
433
 
402
434
  <!-- ── CREATOR PANEL ─────────────────────────────────────────────── -->
@@ -1250,6 +1282,7 @@
1250
1282
  <script src="/ws.js"></script>
1251
1283
  <script src="/ws-dispatcher.js"></script>
1252
1284
  <script src="/sessions.js"></script>
1285
+ <script src="/workspace.js"></script>
1253
1286
  <script src="/datepicker.js"></script>
1254
1287
  <script src="/tasks.js"></script>
1255
1288
  <script src="/skills.js"></script>