openclacky 1.2.6 → 1.2.7

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.
@@ -1396,8 +1396,15 @@ const Sessions = (() => {
1396
1396
  // showProgress() with the authoritative started_at, which is the
1397
1397
  // single source of truth for first-visit sessions (no cached state).
1398
1398
  } else if (session.status === "error" && session.error) {
1399
- // Show the stored error message at the end of history
1400
- Sessions.appendMsg("error", session.error);
1399
+ if (window.renderErrorEvent) {
1400
+ window.renderErrorEvent({
1401
+ code: session.error_code,
1402
+ message: session.error,
1403
+ top_up_url: session.top_up_url,
1404
+ });
1405
+ } else {
1406
+ Sessions.appendMsg("error", session.error);
1407
+ }
1401
1408
  }
1402
1409
  }
1403
1410
  }
@@ -2335,6 +2342,7 @@ const Sessions = (() => {
2335
2342
  /** Update the session info bar below the chat header with current session metadata. */
2336
2343
  updateInfoBar(s) {
2337
2344
  this._lastSession = s;
2345
+ if (window.Workspace) Workspace.onSession(s);
2338
2346
  if (!s) {
2339
2347
  // Hide all spans when no session
2340
2348
  ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
@@ -2393,15 +2401,21 @@ const Sessions = (() => {
2393
2401
  const sibModelWrap = $("sib-model-wrap");
2394
2402
  const sibModel = $("sib-model");
2395
2403
  if (sibModel) {
2396
- sibModel.textContent = s.model || "";
2397
- // Store current session ID on the model element for later use
2404
+ const subModel = s.sub_model;
2405
+ const cardModel = s.card_model;
2406
+ const display = subModel
2407
+ ? `${subModel}`
2408
+ : (s.model || "");
2409
+ sibModel.textContent = display;
2398
2410
  sibModel.dataset.sessionId = s.id;
2399
2411
  if (s.model_id) {
2400
2412
  sibModel.dataset.modelId = s.model_id;
2401
2413
  } else {
2402
2414
  delete sibModel.dataset.modelId;
2403
2415
  }
2404
- // Disable model switching while the agent is responding
2416
+ if (cardModel) sibModel.dataset.cardModel = cardModel; else delete sibModel.dataset.cardModel;
2417
+ if (subModel) sibModel.dataset.subModel = subModel; else delete sibModel.dataset.subModel;
2418
+ sibModel.dataset.subModelOptions = JSON.stringify(s.sub_model_options || []);
2405
2419
  const busy = s.status === "running";
2406
2420
  sibModel.classList.toggle("sib-model-disabled", busy);
2407
2421
  sibModel.title = busy
@@ -3343,8 +3357,16 @@ const Sessions = (() => {
3343
3357
  if (_isOpen) {
3344
3358
  dropdown.style.display = "none";
3345
3359
  _isOpen = false;
3360
+ _closeSubmodelPanel();
3346
3361
  } else {
3347
- await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null);
3362
+ let subOptions = [];
3363
+ try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
3364
+ const subInfo = {
3365
+ options: Array.isArray(subOptions) ? subOptions : [],
3366
+ current: modelEl.dataset.subModel || null,
3367
+ cardModel: modelEl.dataset.cardModel || null
3368
+ };
3369
+ await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
3348
3370
 
3349
3371
  // Calculate position relative to the model element (fixed positioning)
3350
3372
  const rect = modelEl.getBoundingClientRect();
@@ -3359,15 +3381,17 @@ const Sessions = (() => {
3359
3381
  }
3360
3382
 
3361
3383
  // Close dropdown when clicking outside
3362
- if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
3384
+ if (_isOpen && !e.target.closest(".sib-model-dropdown") && !e.target.closest(".sib-submodel-panel")) {
3363
3385
  const dropdown = $("sib-model-dropdown");
3364
3386
  if (dropdown) dropdown.style.display = "none";
3365
3387
  _isOpen = false;
3388
+ _closeSubmodelPanel();
3366
3389
  }
3367
3390
  });
3368
3391
 
3369
3392
  // Populate dropdown with available models
3370
- async function _populateModelDropdown(sessionId, currentModelId) {
3393
+ async function _populateModelDropdown(sessionId, currentModelId, subInfo) {
3394
+ subInfo = subInfo || { options: [], current: null, cardModel: null };
3371
3395
  const dropdown = $("sib-model-dropdown");
3372
3396
  if (!dropdown) return;
3373
3397
 
@@ -3431,6 +3455,13 @@ const Sessions = (() => {
3431
3455
  nameLine.textContent = m.model;
3432
3456
  left.appendChild(nameLine);
3433
3457
 
3458
+ if (m.id === currentModelId && subInfo.current && subInfo.current !== subInfo.cardModel) {
3459
+ const overrideLine = document.createElement("span");
3460
+ overrideLine.className = "sib-model-name-override";
3461
+ overrideLine.textContent = `→ ${subInfo.current}`;
3462
+ left.appendChild(overrideLine);
3463
+ }
3464
+
3434
3465
  if (_nameCounts[m.model] > 1) {
3435
3466
  left.classList.add("has-sub");
3436
3467
  const host = (() => {
@@ -3458,17 +3489,36 @@ const Sessions = (() => {
3458
3489
  right.appendChild(badge);
3459
3490
  }
3460
3491
 
3461
- // Latency cell — populated from _benchCache on open, updated live
3462
- // when a benchmark run completes. Empty slot keeps row heights stable
3463
- // so the list doesn't visually jump mid-benchmark.
3464
3492
  const lat = document.createElement("span");
3465
3493
  lat.className = "sib-model-latency";
3466
3494
  _fillLatencyCell(lat, _benchCache[m.id]);
3467
3495
  right.appendChild(lat);
3468
3496
 
3497
+ const hasSubModels =
3498
+ m.id === currentModelId &&
3499
+ subInfo.options &&
3500
+ subInfo.options.length > 1;
3501
+
3502
+ if (hasSubModels) {
3503
+ const toggleBtn = document.createElement("button");
3504
+ toggleBtn.type = "button";
3505
+ toggleBtn.className = "sib-submodel-toggle";
3506
+ toggleBtn.title = "Switch sub-model";
3507
+ toggleBtn.setAttribute("aria-expanded", "false");
3508
+ toggleBtn.innerHTML =
3509
+ '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
3510
+ '<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
3511
+ '</svg>';
3512
+ right.appendChild(toggleBtn);
3513
+
3514
+ toggleBtn.addEventListener("click", (ev) => {
3515
+ ev.stopPropagation();
3516
+ _toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
3517
+ });
3518
+ }
3519
+
3469
3520
  opt.appendChild(right);
3470
3521
 
3471
- // Switch by id (stable across reorders/edits). Keep model name for UI update.
3472
3522
  opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
3473
3523
  dropdown.appendChild(opt);
3474
3524
  });
@@ -3579,14 +3629,15 @@ const Sessions = (() => {
3579
3629
  }
3580
3630
 
3581
3631
  // Switch session model via API
3582
- // modelId stable runtime id (required by backend)
3583
- // modelName display name, used for optimistic UI update
3632
+ // Switch the session's current card. modelId is the stable runtime id,
3633
+ // modelName is for optimistic display.
3584
3634
  async function _switchModel(sessionId, modelId, modelName) {
3585
3635
  const dropdown = $("sib-model-dropdown");
3586
3636
  if (dropdown) {
3587
3637
  dropdown.style.display = "none";
3588
3638
  _isOpen = false;
3589
3639
  }
3640
+ _closeSubmodelPanel();
3590
3641
 
3591
3642
  try {
3592
3643
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
@@ -3611,6 +3662,144 @@ const Sessions = (() => {
3611
3662
  alert("Failed to switch model: " + e.message);
3612
3663
  }
3613
3664
  }
3665
+
3666
+ // Pin (or clear) the session's sub-model. Pass modelName=null to clear.
3667
+ // displayName is what we optimistically show in the status bar.
3668
+ async function _switchSubModel(sessionId, modelName, displayName) {
3669
+ const dropdown = $("sib-model-dropdown");
3670
+ if (dropdown) {
3671
+ dropdown.style.display = "none";
3672
+ _isOpen = false;
3673
+ }
3674
+ _closeSubmodelPanel();
3675
+
3676
+ try {
3677
+ const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
3678
+ method: "PATCH",
3679
+ headers: { "Content-Type": "application/json" },
3680
+ body: JSON.stringify({ model_name: modelName })
3681
+ });
3682
+ const data = await res.json();
3683
+ if (!res.ok) throw new Error(data.error || "Unknown error");
3684
+
3685
+ const sibModel = $("sib-model");
3686
+ if (sibModel) {
3687
+ sibModel.textContent = displayName || "";
3688
+ sibModel.dataset.subModel = modelName || "";
3689
+ }
3690
+ } catch (e) {
3691
+ console.error("Failed to switch sub-model:", e);
3692
+ alert("Failed to switch sub-model: " + e.message);
3693
+ }
3694
+ }
3695
+
3696
+ let _activeSubmodelAnchor = null;
3697
+
3698
+ function _closeSubmodelPanel() {
3699
+ const panel = $("sib-submodel-panel");
3700
+ if (panel) panel.style.display = "none";
3701
+ if (_activeSubmodelAnchor) {
3702
+ const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
3703
+ if (btn) btn.setAttribute("aria-expanded", "false");
3704
+ _activeSubmodelAnchor.classList.remove("submodel-open");
3705
+ _activeSubmodelAnchor = null;
3706
+ }
3707
+ }
3708
+
3709
+ function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
3710
+ const panel = $("sib-submodel-panel");
3711
+ const dropdown = $("sib-model-dropdown");
3712
+ if (!panel || !dropdown) return;
3713
+
3714
+ if (panel.parentElement !== document.body) {
3715
+ document.body.appendChild(panel);
3716
+ }
3717
+
3718
+ const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
3719
+ if (isOpen) {
3720
+ _closeSubmodelPanel();
3721
+ return;
3722
+ }
3723
+
3724
+ _renderSubmodelPanel(panel, sessionId, subInfo);
3725
+
3726
+ // Reset any prior position so measurements are accurate.
3727
+ panel.style.left = "0px";
3728
+ panel.style.top = "0px";
3729
+ panel.style.display = "block";
3730
+ panel.style.visibility = "hidden";
3731
+
3732
+ const dropRect = dropdown.getBoundingClientRect();
3733
+ const btnRect = btn.getBoundingClientRect();
3734
+ const panelRect = panel.getBoundingClientRect();
3735
+ const gap = 6;
3736
+ const margin = 8;
3737
+ const vw = window.innerWidth;
3738
+ const vh = window.innerHeight;
3739
+
3740
+ // Prefer right of dropdown; flip to left if we'd overflow viewport.
3741
+ let left = dropRect.right + gap;
3742
+ if (left + panelRect.width > vw - margin) {
3743
+ left = dropRect.left - panelRect.width - gap;
3744
+ }
3745
+ // If still off-screen on the left, clamp inside viewport.
3746
+ if (left < margin) left = margin;
3747
+
3748
+ // Vertically align to the chevron button, but clamp inside viewport.
3749
+ let top = btnRect.top - 6;
3750
+ if (top + panelRect.height > vh - margin) {
3751
+ top = vh - margin - panelRect.height;
3752
+ }
3753
+ if (top < margin) top = margin;
3754
+
3755
+ panel.style.left = `${left}px`;
3756
+ panel.style.top = `${top}px`;
3757
+ panel.style.visibility = "";
3758
+
3759
+ _activeSubmodelAnchor = anchorRow;
3760
+ anchorRow.classList.add("submodel-open");
3761
+ btn.setAttribute("aria-expanded", "true");
3762
+ }
3763
+
3764
+ function _renderSubmodelPanel(panel, sessionId, subInfo) {
3765
+ panel.innerHTML = "";
3766
+
3767
+ const header = document.createElement("div");
3768
+ header.className = "sib-submodel-panel-header";
3769
+ header.textContent = "Sub-model";
3770
+ panel.appendChild(header);
3771
+
3772
+ const cardDefault = subInfo.cardModel;
3773
+ subInfo.options.forEach(name => {
3774
+ const row = document.createElement("div");
3775
+ row.className = "sib-submodel-row";
3776
+ row.dataset.subModel = name;
3777
+
3778
+ const isActive = subInfo.current
3779
+ ? name === subInfo.current
3780
+ : name === cardDefault;
3781
+ if (isActive) row.classList.add("current");
3782
+
3783
+ const nameEl = document.createElement("span");
3784
+ nameEl.className = "sib-submodel-row-name";
3785
+ nameEl.textContent = name;
3786
+ row.appendChild(nameEl);
3787
+
3788
+ if (name === cardDefault) {
3789
+ const tag = document.createElement("span");
3790
+ tag.className = "sib-submodel-default-tag";
3791
+ tag.textContent = "default";
3792
+ row.appendChild(tag);
3793
+ }
3794
+
3795
+ row.addEventListener("click", (ev) => {
3796
+ ev.stopPropagation();
3797
+ const passName = (name === cardDefault) ? null : name;
3798
+ _switchSubModel(sessionId, passName, name);
3799
+ });
3800
+ panel.appendChild(row);
3801
+ });
3802
+ }
3614
3803
  })();
3615
3804
 
3616
3805
  // ── Session Info Bar Working Directory Switcher ───────────────────────────
@@ -6,6 +6,11 @@ const Settings = (() => {
6
6
  let _models = [];
7
7
  // Provider presets loaded from server
8
8
  let _providers = [];
9
+ // Provider id selected in the model edit modal (null when "Custom" or unset).
10
+ // Used to opt the request into anthropic_format=true when the user picks
11
+ // the Anthropic provider; other providers leave the flag unset and let the
12
+ // backend's runtime inference decide.
13
+ let _modalSelectedProviderId = null;
9
14
 
10
15
  // ── Public API ──────────────────────────────────────────────────────────────
11
16
 
@@ -52,9 +57,14 @@ const Settings = (() => {
52
57
  }
53
58
 
54
59
  function _getProviderName(model) {
55
- if (!model.base_url) return I18n.t("settings.models.provider.custom");
56
- const url = model.base_url.trim().replace(/\/+$/, "");
57
- const provider = _providers.find(p => {
60
+ const p = _findProviderByBaseUrl(model.base_url);
61
+ return p ? p.name : I18n.t("settings.models.provider.custom");
62
+ }
63
+
64
+ function _findProviderByBaseUrl(baseUrl) {
65
+ if (!baseUrl) return null;
66
+ const url = String(baseUrl).trim().replace(/\/+$/, "");
67
+ return _providers.find(p => {
58
68
  const candidates = [p.base_url].concat(
59
69
  Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
60
70
  ).filter(Boolean);
@@ -62,14 +72,15 @@ const Settings = (() => {
62
72
  const norm = String(c).replace(/\/+$/, "");
63
73
  return url === norm || url.startsWith(norm + "/");
64
74
  });
65
- });
66
- return provider ? provider.name : I18n.t("settings.models.provider.custom");
75
+ }) || null;
67
76
  }
68
77
 
69
78
  function _renderCard(container, model, index) {
70
79
  const isDefault = model.type === "default";
71
80
  const isLite = model.type === "lite";
72
- const providerName = _getProviderName(model);
81
+ const provider = _findProviderByBaseUrl(model.base_url);
82
+ const providerName = provider ? provider.name : I18n.t("settings.models.provider.custom");
83
+ const websiteUrl = provider && provider.website_url;
73
84
  const displayName = model.model || I18n.t("settings.models.unnamed");
74
85
 
75
86
  const card = document.createElement("div");
@@ -84,7 +95,7 @@ const Settings = (() => {
84
95
  ${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
85
96
  </div>
86
97
  <div class="model-card-grid-provider">${_esc(providerName)}</div>
87
- ${model.model ? `<div class="model-card-grid-model">${_esc(model.model)}</div>` : ""}
98
+ ${model.api_key_masked ? `<div class="model-card-grid-model">${_esc(model.api_key_masked)}</div>` : ""}
88
99
  <div class="model-card-grid-status">
89
100
  <span class="model-test-result" data-index="${index}"></span>
90
101
  </div>
@@ -107,6 +118,12 @@ const Settings = (() => {
107
118
  <span>${I18n.t("settings.models.btn.delete")}</span>
108
119
  </button>` : ""}
109
120
  </div>
121
+ ${websiteUrl ? `<div class="model-card-grid-footer">
122
+ <a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
123
+ ${I18n.t("settings.models.link.topUp")}
124
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
125
+ </a>
126
+ </div>` : ""}
110
127
  `;
111
128
 
112
129
  container.appendChild(card);
@@ -150,7 +167,11 @@ const Settings = (() => {
150
167
  document.getElementById("model-modal-default-field").style.display = "none";
151
168
 
152
169
  // Set provider dropdown value
153
- const providerName = _getProviderName(model);
170
+ const matched = _findProviderByBaseUrl(model.base_url);
171
+ // Preserve an explicit anthropic_format=true even if base_url is custom:
172
+ // the user may have configured a self-hosted Anthropic-compatible proxy.
173
+ _modalSelectedProviderId = matched ? matched.id : (model.anthropic_format ? "anthropic" : null);
174
+ const providerName = matched ? matched.name : I18n.t("settings.models.provider.custom");
154
175
  const providerValue = document.getElementById("model-modal-provider-value");
155
176
  providerValue.textContent = providerName;
156
177
  providerValue.classList.remove("placeholder");
@@ -164,6 +185,7 @@ const Settings = (() => {
164
185
  document.getElementById("model-modal-set-default").checked = true;
165
186
 
166
187
  // Reset provider dropdown
188
+ _modalSelectedProviderId = null;
167
189
  const providerValue = document.getElementById("model-modal-provider-value");
168
190
  providerValue.textContent = I18n.t("settings.models.placeholder.provider");
169
191
  providerValue.classList.add("placeholder");
@@ -208,6 +230,10 @@ const Settings = (() => {
208
230
  const value = option.dataset.value;
209
231
  const text = option.dataset.label || option.textContent.trim();
210
232
 
233
+ // Track the picked provider so test/save can flag anthropic_format=true
234
+ // when the user explicitly picks Anthropic. Empty / "custom" → null.
235
+ _modalSelectedProviderId = (value && value !== "custom") ? value : null;
236
+
211
237
  const providerValue = document.getElementById("model-modal-provider-value");
212
238
  providerValue.textContent = text;
213
239
  providerValue.classList.toggle("placeholder", !value);
@@ -256,15 +282,18 @@ const Settings = (() => {
256
282
  _showTestResult(index, null, "");
257
283
 
258
284
  try {
285
+ const body = {
286
+ model: model.model,
287
+ base_url: model.base_url,
288
+ api_key: model.api_key_masked,
289
+ index
290
+ };
291
+ if (model.anthropic_format) body.anthropic_format = true;
292
+
259
293
  const testRes = await fetch("/api/config/test", {
260
294
  method: "POST",
261
295
  headers: { "Content-Type": "application/json" },
262
- body: JSON.stringify({
263
- model: model.model,
264
- base_url: model.base_url,
265
- api_key: model.api_key_masked,
266
- index
267
- })
296
+ body: JSON.stringify(body)
268
297
  });
269
298
  const testData = await testRes.json();
270
299
  _showTestResult(index, testData.ok, testData.message);
@@ -285,15 +314,25 @@ const Settings = (() => {
285
314
 
286
315
  saveBtn.disabled = true;
287
316
 
317
+ // Anthropic protocol is opted in only when the user picks the Anthropic
318
+ // provider in the modal. Other providers leave the flag absent so the
319
+ // backend's runtime inference (provider preset + model api overrides)
320
+ // decides — preserving e.g. OpenRouter's per-model anthropic-messages
321
+ // routing for Claude sub-models.
322
+ const anthropic_format = _modalSelectedProviderId === "anthropic";
323
+
288
324
  // Step 1: Test first
289
325
  saveBtn.textContent = I18n.t("settings.models.btn.testing");
290
326
  _showModalTestResult(null, "");
291
327
 
292
328
  try {
329
+ const testBody = { model, base_url, api_key, index };
330
+ if (anthropic_format) testBody.anthropic_format = true;
331
+
293
332
  const testRes = await fetch("/api/config/test", {
294
333
  method: "POST",
295
334
  headers: { "Content-Type": "application/json" },
296
- body: JSON.stringify({ model, base_url, api_key, index })
335
+ body: JSON.stringify(testBody)
297
336
  });
298
337
  const testData = await testRes.json();
299
338
  _showModalTestResult(testData.ok, testData.message);
@@ -317,7 +356,7 @@ const Settings = (() => {
317
356
  const existing = isNew ? {} : (_models[index] || {});
318
357
  const hasId = !!existing.id;
319
358
 
320
- const payload = { model, base_url, anthropic_format: false };
359
+ const payload = { model, base_url, anthropic_format };
321
360
  if (isNew) {
322
361
  if (document.getElementById("model-modal-set-default").checked) {
323
362
  payload.type = "default";
@@ -886,7 +925,10 @@ const Settings = (() => {
886
925
  model: card.querySelector(`[data-key="model"]`).value.trim(),
887
926
  base_url: card.querySelector(`[data-key="base_url"]`).value.trim(),
888
927
  api_key: card.querySelector(`[data-key="api_key"]`).value.trim(),
889
- anthropic_format: false,
928
+ // The inline card form has no provider picker — preserve whatever the
929
+ // model was saved with. The modal flow is the only place where the
930
+ // user can flip this flag.
931
+ anthropic_format: !!_models[index]?.anthropic_format,
890
932
  type: _models[index]?.type ?? null
891
933
  };
892
934
  }
@@ -0,0 +1,204 @@
1
+ // Workspace panel — lazy file tree for the active session's working directory.
2
+ // Lists one directory level at a time via GET /api/sessions/:id/files,
3
+ // expands/collapses folders in place, and downloads files on click via
4
+ // POST /api/file-action.
5
+ "use strict";
6
+
7
+ const Workspace = (() => {
8
+ const STORAGE_KEY = "clacky.workspace.open";
9
+
10
+ let _sessionId = null;
11
+ let _workingDir = null;
12
+ let _open = false;
13
+
14
+ const $ = (id) => document.getElementById(id);
15
+ const t = (key) => (typeof I18n !== "undefined" ? I18n.t(key) : key);
16
+
17
+ const ICON_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>';
18
+ const ICON_FILE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
19
+ const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
20
+
21
+ function formatSize(bytes) {
22
+ if (bytes == null) return "";
23
+ if (bytes < 1024) return `${bytes} B`;
24
+ const units = ["KB", "MB", "GB", "TB"];
25
+ let n = bytes / 1024, i = 0;
26
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
27
+ return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
28
+ }
29
+
30
+ async function fetchEntries(relPath) {
31
+ const url = `/api/sessions/${encodeURIComponent(_sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
32
+ const resp = await fetch(url);
33
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
34
+ const data = await resp.json();
35
+ return data.entries || [];
36
+ }
37
+
38
+ function renderEntries(entries) {
39
+ const frag = document.createDocumentFragment();
40
+ if (!entries.length) {
41
+ const empty = document.createElement("div");
42
+ empty.className = "wt-empty";
43
+ empty.textContent = t("workspace.empty");
44
+ frag.appendChild(empty);
45
+ return frag;
46
+ }
47
+ for (const entry of entries) {
48
+ frag.appendChild(buildNode(entry));
49
+ }
50
+ return frag;
51
+ }
52
+
53
+ function buildNode(entry) {
54
+ const node = document.createElement("div");
55
+ node.className = "wt-node";
56
+
57
+ const row = document.createElement("div");
58
+ row.className = "wt-row";
59
+ row.title = entry.name;
60
+
61
+ const caret = document.createElement("span");
62
+ caret.className = "wt-caret" + (entry.type === "dir" ? "" : " leaf");
63
+ if (entry.type === "dir") caret.innerHTML = ICON_CARET;
64
+
65
+ const icon = document.createElement("span");
66
+ icon.className = "wt-icon";
67
+ icon.innerHTML = entry.type === "dir" ? ICON_FOLDER : ICON_FILE;
68
+
69
+ const name = document.createElement("span");
70
+ name.className = "wt-name";
71
+ name.textContent = entry.name;
72
+
73
+ row.appendChild(caret);
74
+ row.appendChild(icon);
75
+ row.appendChild(name);
76
+
77
+ if (entry.type === "file") {
78
+ const size = document.createElement("span");
79
+ size.className = "wt-size";
80
+ size.textContent = formatSize(entry.size);
81
+ row.appendChild(size);
82
+ }
83
+
84
+ node.appendChild(row);
85
+
86
+ if (entry.type === "dir") {
87
+ const children = document.createElement("div");
88
+ children.className = "wt-children";
89
+ children.style.display = "none";
90
+ node.appendChild(children);
91
+ row.addEventListener("click", () => toggleDir(entry, caret, children));
92
+ } else {
93
+ row.addEventListener("click", () => downloadFile(entry));
94
+ }
95
+
96
+ return node;
97
+ }
98
+
99
+ async function toggleDir(entry, caret, children) {
100
+ const isOpen = caret.classList.contains("open");
101
+ if (isOpen) {
102
+ caret.classList.remove("open");
103
+ children.style.display = "none";
104
+ return;
105
+ }
106
+ caret.classList.add("open");
107
+ children.style.display = "";
108
+ if (children.dataset.loaded === "1") return;
109
+
110
+ children.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
111
+ try {
112
+ const entries = await fetchEntries(entry.path);
113
+ children.innerHTML = "";
114
+ children.appendChild(renderEntries(entries));
115
+ children.dataset.loaded = "1";
116
+ } catch (err) {
117
+ console.error("workspace load failed:", err);
118
+ children.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
119
+ }
120
+ }
121
+
122
+ async function downloadFile(entry) {
123
+ const fullPath = _workingDir.replace(/\/+$/, "") + "/" + entry.path;
124
+ try {
125
+ const resp = await fetch("/api/file-action", {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({ path: fullPath, action: "download" })
129
+ });
130
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
131
+ const blob = await resp.blob();
132
+ const url = URL.createObjectURL(blob);
133
+ const a = document.createElement("a");
134
+ a.href = url;
135
+ a.download = entry.name;
136
+ document.body.appendChild(a);
137
+ a.click();
138
+ a.remove();
139
+ URL.revokeObjectURL(url);
140
+ } catch (err) {
141
+ console.error("download failed:", err);
142
+ if (typeof Modal !== "undefined") Modal.toast(t("workspace.downloadFailed"), "error");
143
+ }
144
+ }
145
+
146
+ async function loadRoot() {
147
+ const tree = $("workspace-tree");
148
+ if (!tree || !_sessionId) return;
149
+ tree.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
150
+ try {
151
+ const entries = await fetchEntries("");
152
+ tree.innerHTML = "";
153
+ tree.appendChild(renderEntries(entries));
154
+ } catch (err) {
155
+ console.error("workspace load failed:", err);
156
+ tree.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
157
+ }
158
+ }
159
+
160
+ function applyOpenState() {
161
+ const panel = $("workspace-panel");
162
+ const opener = $("btn-workspace-open");
163
+ if (!panel) return;
164
+ const hasSession = !!_sessionId;
165
+ panel.classList.toggle("collapsed", !(_open && hasSession));
166
+ if (opener) opener.style.display = (!_open && hasSession) ? "" : "none";
167
+ }
168
+
169
+ function setOpen(open) {
170
+ _open = open;
171
+ try { localStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch (_) {}
172
+ applyOpenState();
173
+ if (open) loadRoot();
174
+ }
175
+
176
+ return {
177
+ init() {
178
+ try { _open = localStorage.getItem(STORAGE_KEY) === "1"; } catch (_) { _open = false; }
179
+
180
+ const close = $("btn-workspace-close");
181
+ const opener = $("btn-workspace-open");
182
+ const refresh = $("btn-workspace-refresh");
183
+ if (close) close.addEventListener("click", () => setOpen(false));
184
+ if (opener) opener.addEventListener("click", () => setOpen(true));
185
+ if (refresh) refresh.addEventListener("click", () => loadRoot());
186
+
187
+ applyOpenState();
188
+ },
189
+
190
+ // Called from Sessions.updateInfoBar whenever the active session changes.
191
+ onSession(session) {
192
+ const newId = session ? session.id : null;
193
+ const newDir = session ? session.working_dir : null;
194
+ const changed = newId !== _sessionId || newDir !== _workingDir;
195
+ _sessionId = newId;
196
+ _workingDir = newDir;
197
+ applyOpenState();
198
+ if (changed && _open && _sessionId) loadRoot();
199
+ }
200
+ };
201
+ })();
202
+
203
+ document.addEventListener("DOMContentLoaded", () => Workspace.init());
204
+ window.Workspace = Workspace;