black-yt commited on
Commit
e81bd91
·
1 Parent(s): 11d2469

Add workspace zip download

Browse files
README.md CHANGED
@@ -67,6 +67,10 @@ These behaviors are intentional Space-only deltas:
67
  `run_.../agent_trace/` for traces and `_session_state.json`.
68
  - Uploaded images are saved under `agent_workspace/inputs/images/` and are also
69
  passed to the model as image inputs when supported.
 
 
 
 
70
  - The frontend exposes a per-run model dropdown. Current options are `gpt-5.5`
71
  and `claude-opus-4-7`; the selection must stay local to that run and must not
72
  mutate global process environment variables.
@@ -132,7 +136,9 @@ Configure these as Hugging Face Space secrets before starting the app:
132
  └── agent_trace/ # trace JSONL and _session_state.json
133
  ```
134
 
135
- The frontend only exposes the chat UI. The workspace path is managed by the server so hosted users cannot browse or select server folders.
 
 
136
 
137
  ## Trajectory Collection
138
 
 
67
  `run_.../agent_trace/` for traces and `_session_state.json`.
68
  - Uploaded images are saved under `agent_workspace/inputs/images/` and are also
69
  passed to the model as image inputs when supported.
70
+ - Users can download files created or handled by the agent with the
71
+ `Download workspace.zip` button. The zip contains only the current chat's
72
+ `agent_workspace/`; it does not include `agent_trace/`, server files, or
73
+ Space secrets.
74
  - The frontend exposes a per-run model dropdown. Current options are `gpt-5.5`
75
  and `claude-opus-4-7`; the selection must stay local to that run and must not
76
  mutate global process environment variables.
 
136
  └── agent_trace/ # trace JSONL and _session_state.json
137
  ```
138
 
139
+ The frontend exposes the chat UI and a single `Download workspace.zip` action
140
+ for the current chat. The workspace path is managed by the server so hosted
141
+ users cannot browse or select server folders.
142
 
143
  ## Trajectory Collection
144
 
check_space_runtime.py CHANGED
@@ -2,8 +2,11 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import tempfile
 
5
  from pathlib import Path
6
 
 
 
7
  from frontend import local_server
8
 
9
 
@@ -36,6 +39,18 @@ def main() -> int:
36
  run_root = Path(bridge.managed_run_root)
37
  (workspace_root / "answer.txt").write_text("ok\n", encoding="utf-8")
38
  Path(trace_dir, "trace.jsonl").write_text('{"ok": true}\n', encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
39
  bundle = local_server._write_collection_bundle(
40
  run_root,
41
  {"result_text": "ok", "termination": "result"},
 
2
 
3
  import asyncio
4
  import tempfile
5
+ import zipfile
6
  from pathlib import Path
7
 
8
+ from fastapi.testclient import TestClient
9
+
10
  from frontend import local_server
11
 
12
 
 
39
  run_root = Path(bridge.managed_run_root)
40
  (workspace_root / "answer.txt").write_text("ok\n", encoding="utf-8")
41
  Path(trace_dir, "trace.jsonl").write_text('{"ok": true}\n', encoding="utf-8")
42
+ client = TestClient(local_server.app)
43
+ zip_response = client.get(f"/api/workspace.zip?token={bridge.download_token}")
44
+ if zip_response.status_code != 200:
45
+ raise RuntimeError(f"workspace zip endpoint returned {zip_response.status_code}")
46
+ workspace_zip = Path(tmp) / "workspace.zip"
47
+ workspace_zip.write_bytes(zip_response.content)
48
+ with zipfile.ZipFile(workspace_zip) as archive:
49
+ names = set(archive.namelist())
50
+ if "answer.txt" not in names:
51
+ raise RuntimeError("workspace zip did not include agent workspace file")
52
+ if any(name.startswith("agent_trace/") for name in names):
53
+ raise RuntimeError("workspace zip must not include trace files")
54
  bundle = local_server._write_collection_bundle(
55
  run_root,
56
  {"result_text": "ok", "termination": "result"},
frontend/local_server.py CHANGED
@@ -7,6 +7,7 @@ import json
7
  import os
8
  import re
9
  import shutil
 
10
  import threading
11
  import time
12
  import traceback
@@ -15,9 +16,10 @@ from pathlib import Path
15
  from typing import Any
16
  from uuid import uuid4
17
 
18
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
19
  from fastapi.responses import FileResponse
20
  from fastapi.staticfiles import StaticFiles
 
21
 
22
  from agent_base.react_agent import MultiTurnReactAgent, default_llm_config
23
  from agent_base.utils import (
@@ -35,6 +37,8 @@ from agent_base.utils import (
35
  STATIC_DIR = Path(__file__).resolve().parent / "static"
36
  MAX_UPLOAD_IMAGES = 12
37
  MAX_IMAGE_BYTES = 12 * 1024 * 1024
 
 
38
  FRONTEND_MANAGED_RUNS_DIR: str | None = None
39
  FRONTEND_CLEANUP_RETENTION_SECONDS = 6 * 60 * 60
40
  FRONTEND_CLEANUP_MAX_RUNS = 40
@@ -46,6 +50,8 @@ FRONTEND_COLLECTION_MAX_BUNDLE_BYTES = 20 * 1024 * 1024
46
  _CLEANUP_THREAD_STARTED = False
47
  _ACTIVE_MANAGED_RUNS: set[str] = set()
48
  _ACTIVE_MANAGED_RUNS_LOCK = threading.Lock()
 
 
49
  _COLLECTION_LOCK = threading.Lock()
50
  _COLLECTION_CONFIG_WARNED: set[str] = set()
51
 
@@ -104,6 +110,7 @@ class FrontendRunBridge:
104
  self.managed_run_root: str = ""
105
  self.managed_workspace_root: str = ""
106
  self.managed_trace_dir: str = ""
 
107
  self._pending_answers: dict[str, str] = {}
108
  self._pending_events: dict[str, threading.Event] = {}
109
  self._lock = threading.Lock()
@@ -167,13 +174,40 @@ def _mark_managed_run_active(run_root: Path) -> None:
167
  _ACTIVE_MANAGED_RUNS.add(str(run_root.resolve()))
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  def _release_managed_run(bridge: FrontendRunBridge) -> None:
 
171
  if bridge.managed_run_root:
172
  with _ACTIVE_MANAGED_RUNS_LOCK:
173
  _ACTIVE_MANAGED_RUNS.discard(str(Path(bridge.managed_run_root).resolve()))
174
  bridge.managed_run_root = ""
175
  bridge.managed_workspace_root = ""
176
  bridge.managed_trace_dir = ""
 
177
 
178
 
179
  def _create_managed_run(bridge: FrontendRunBridge) -> tuple[Path, str]:
@@ -185,6 +219,7 @@ def _create_managed_run(bridge: FrontendRunBridge) -> tuple[Path, str]:
185
  bridge.managed_run_root = str(run_root)
186
  bridge.managed_workspace_root = str(workspace_root)
187
  bridge.managed_trace_dir = str(trace_dir)
 
188
  _mark_managed_run_active(run_root)
189
  return workspace_root, str(trace_dir)
190
 
@@ -439,6 +474,46 @@ def _collect_finished_managed_run(run_root_text: str, result: dict[str, Any]) ->
439
  threading.Thread(target=_flush_collection_batches, daemon=True).start()
440
 
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  class FrontendInteractiveAgent(MultiTurnReactAgent):
443
  def __init__(self, *, bridge: FrontendRunBridge, **kwargs: Any):
444
  super().__init__(**kwargs)
@@ -546,6 +621,7 @@ def _run_agent_thread(
546
  "model": agent.model,
547
  "workspace_root": str(workspace_root),
548
  "trace_dir": trace_dir,
 
549
  }
550
  )
551
  result = agent._run_session(
@@ -583,6 +659,19 @@ def favicon() -> FileResponse:
583
  return FileResponse(STATIC_DIR / "favicon.svg", media_type="image/svg+xml")
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  @app.websocket("/ws")
587
  async def websocket_endpoint(websocket: WebSocket) -> None:
588
  await websocket.accept()
 
7
  import os
8
  import re
9
  import shutil
10
+ import tempfile
11
  import threading
12
  import time
13
  import traceback
 
16
  from typing import Any
17
  from uuid import uuid4
18
 
19
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
20
  from fastapi.responses import FileResponse
21
  from fastapi.staticfiles import StaticFiles
22
+ from starlette.background import BackgroundTask
23
 
24
  from agent_base.react_agent import MultiTurnReactAgent, default_llm_config
25
  from agent_base.utils import (
 
37
  STATIC_DIR = Path(__file__).resolve().parent / "static"
38
  MAX_UPLOAD_IMAGES = 12
39
  MAX_IMAGE_BYTES = 12 * 1024 * 1024
40
+ MAX_WORKSPACE_DOWNLOAD_BYTES = 100 * 1024 * 1024
41
+ MAX_WORKSPACE_DOWNLOAD_FILES = 5000
42
  FRONTEND_MANAGED_RUNS_DIR: str | None = None
43
  FRONTEND_CLEANUP_RETENTION_SECONDS = 6 * 60 * 60
44
  FRONTEND_CLEANUP_MAX_RUNS = 40
 
50
  _CLEANUP_THREAD_STARTED = False
51
  _ACTIVE_MANAGED_RUNS: set[str] = set()
52
  _ACTIVE_MANAGED_RUNS_LOCK = threading.Lock()
53
+ _DOWNLOAD_WORKSPACES: dict[str, str] = {}
54
+ _DOWNLOAD_WORKSPACES_LOCK = threading.Lock()
55
  _COLLECTION_LOCK = threading.Lock()
56
  _COLLECTION_CONFIG_WARNED: set[str] = set()
57
 
 
110
  self.managed_run_root: str = ""
111
  self.managed_workspace_root: str = ""
112
  self.managed_trace_dir: str = ""
113
+ self.download_token: str = ""
114
  self._pending_answers: dict[str, str] = {}
115
  self._pending_events: dict[str, threading.Event] = {}
116
  self._lock = threading.Lock()
 
174
  _ACTIVE_MANAGED_RUNS.add(str(run_root.resolve()))
175
 
176
 
177
+ def _register_download_workspace(workspace_root: Path) -> str:
178
+ token = uuid4().hex
179
+ with _DOWNLOAD_WORKSPACES_LOCK:
180
+ _DOWNLOAD_WORKSPACES[token] = str(workspace_root.resolve())
181
+ return token
182
+
183
+
184
+ def _unregister_download_workspace(token: str) -> None:
185
+ if not token:
186
+ return
187
+ with _DOWNLOAD_WORKSPACES_LOCK:
188
+ _DOWNLOAD_WORKSPACES.pop(token, None)
189
+
190
+
191
+ def _download_workspace_for_token(token: str) -> Path:
192
+ with _DOWNLOAD_WORKSPACES_LOCK:
193
+ workspace_text = _DOWNLOAD_WORKSPACES.get(str(token or ""))
194
+ if not workspace_text:
195
+ raise HTTPException(status_code=404, detail="No downloadable workspace is available for this chat.")
196
+ workspace_root = Path(workspace_text).resolve()
197
+ if not workspace_root.is_dir():
198
+ raise HTTPException(status_code=404, detail="The workspace is no longer available.")
199
+ return workspace_root
200
+
201
+
202
  def _release_managed_run(bridge: FrontendRunBridge) -> None:
203
+ _unregister_download_workspace(bridge.download_token)
204
  if bridge.managed_run_root:
205
  with _ACTIVE_MANAGED_RUNS_LOCK:
206
  _ACTIVE_MANAGED_RUNS.discard(str(Path(bridge.managed_run_root).resolve()))
207
  bridge.managed_run_root = ""
208
  bridge.managed_workspace_root = ""
209
  bridge.managed_trace_dir = ""
210
+ bridge.download_token = ""
211
 
212
 
213
  def _create_managed_run(bridge: FrontendRunBridge) -> tuple[Path, str]:
 
219
  bridge.managed_run_root = str(run_root)
220
  bridge.managed_workspace_root = str(workspace_root)
221
  bridge.managed_trace_dir = str(trace_dir)
222
+ bridge.download_token = _register_download_workspace(workspace_root)
223
  _mark_managed_run_active(run_root)
224
  return workspace_root, str(trace_dir)
225
 
 
474
  threading.Thread(target=_flush_collection_batches, daemon=True).start()
475
 
476
 
477
+ def _workspace_download_files(workspace_root: Path) -> list[Path]:
478
+ files: list[Path] = []
479
+ total_bytes = 0
480
+ for path in sorted(workspace_root.rglob("*")):
481
+ if path.is_symlink() or not path.is_file():
482
+ continue
483
+ try:
484
+ resolved = path.resolve()
485
+ resolved.relative_to(workspace_root)
486
+ size = resolved.stat().st_size
487
+ except (OSError, ValueError):
488
+ continue
489
+ files.append(resolved)
490
+ total_bytes += size
491
+ if len(files) > MAX_WORKSPACE_DOWNLOAD_FILES:
492
+ raise HTTPException(status_code=413, detail="Workspace has too many files to download as one zip.")
493
+ if total_bytes > MAX_WORKSPACE_DOWNLOAD_BYTES:
494
+ raise HTTPException(status_code=413, detail="Workspace is too large to download as one zip.")
495
+ if not files:
496
+ raise HTTPException(status_code=404, detail="The agent workspace has no downloadable files yet.")
497
+ return files
498
+
499
+
500
+ def _create_workspace_zip(workspace_root: Path) -> Path:
501
+ files = _workspace_download_files(workspace_root)
502
+ handle = tempfile.NamedTemporaryFile(prefix="rh_workspace_", suffix=".zip", delete=False)
503
+ zip_path = Path(handle.name)
504
+ handle.close()
505
+ try:
506
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
507
+ for path in files:
508
+ archive.write(path, path.relative_to(workspace_root).as_posix())
509
+ if zip_path.stat().st_size > MAX_WORKSPACE_DOWNLOAD_BYTES:
510
+ raise HTTPException(status_code=413, detail="Workspace zip is too large to download.")
511
+ except Exception:
512
+ zip_path.unlink(missing_ok=True)
513
+ raise
514
+ return zip_path
515
+
516
+
517
  class FrontendInteractiveAgent(MultiTurnReactAgent):
518
  def __init__(self, *, bridge: FrontendRunBridge, **kwargs: Any):
519
  super().__init__(**kwargs)
 
621
  "model": agent.model,
622
  "workspace_root": str(workspace_root),
623
  "trace_dir": trace_dir,
624
+ "download_token": bridge.download_token,
625
  }
626
  )
627
  result = agent._run_session(
 
659
  return FileResponse(STATIC_DIR / "favicon.svg", media_type="image/svg+xml")
660
 
661
 
662
+ @app.get("/api/workspace.zip")
663
+ def download_workspace_zip(token: str) -> FileResponse:
664
+ workspace_root = _download_workspace_for_token(token)
665
+ zip_path = _create_workspace_zip(workspace_root)
666
+ filename = f"{workspace_root.parent.name}_agent_workspace.zip"
667
+ return FileResponse(
668
+ zip_path,
669
+ media_type="application/zip",
670
+ filename=filename,
671
+ background=BackgroundTask(lambda path: Path(path).unlink(missing_ok=True), str(zip_path)),
672
+ )
673
+
674
+
675
  @app.websocket("/ws")
676
  async def websocket_endpoint(websocket: WebSocket) -> None:
677
  await websocket.accept()
frontend/static/app.css CHANGED
@@ -319,9 +319,10 @@ button {
319
  position: sticky;
320
  top: 66px;
321
  z-index: 8;
322
- display: grid;
323
- grid-template-columns: minmax(0, 1fr);
324
  align-items: center;
 
 
325
  margin-top: 10px;
326
  border-radius: 18px;
327
  padding: 9px 12px;
@@ -336,7 +337,6 @@ button {
336
  .workspace-strip span {
337
  display: block;
338
  min-width: 0;
339
- max-width: 100%;
340
  color: var(--muted);
341
  font-size: 0.82rem;
342
  overflow-wrap: anywhere;
@@ -344,6 +344,24 @@ button {
344
  white-space: normal;
345
  }
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  .messages {
348
  display: flex;
349
  flex-direction: column;
@@ -904,13 +922,18 @@ button:disabled {
904
  }
905
 
906
  .workspace-strip {
907
- display: grid;
 
908
  }
909
 
910
  .workspace-strip span {
911
  max-width: none;
912
  }
913
 
 
 
 
 
914
  .message,
915
  .event {
916
  max-width: 96%;
 
319
  position: sticky;
320
  top: 66px;
321
  z-index: 8;
322
+ display: flex;
 
323
  align-items: center;
324
+ justify-content: space-between;
325
+ gap: 12px;
326
  margin-top: 10px;
327
  border-radius: 18px;
328
  padding: 9px 12px;
 
337
  .workspace-strip span {
338
  display: block;
339
  min-width: 0;
 
340
  color: var(--muted);
341
  font-size: 0.82rem;
342
  overflow-wrap: anywhere;
 
344
  white-space: normal;
345
  }
346
 
347
+ .download-workspace {
348
+ flex: 0 0 auto;
349
+ border: 1px solid var(--border);
350
+ border-radius: 999px;
351
+ background: var(--panel-strong);
352
+ color: var(--text);
353
+ font-size: 0.78rem;
354
+ font-weight: 850;
355
+ padding: 7px 11px;
356
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
357
+ }
358
+
359
+ .download-workspace:hover:not(:disabled),
360
+ .download-workspace:focus-visible {
361
+ border-color: rgba(var(--glow-rgb), 0.38);
362
+ transform: translateY(-1px);
363
+ }
364
+
365
  .messages {
366
  display: flex;
367
  flex-direction: column;
 
922
  }
923
 
924
  .workspace-strip {
925
+ align-items: stretch;
926
+ flex-direction: column;
927
  }
928
 
929
  .workspace-strip span {
930
  max-width: none;
931
  }
932
 
933
+ .download-workspace {
934
+ width: 100%;
935
+ }
936
+
937
  .message,
938
  .event {
939
  max-width: 96%;
frontend/static/app.js CHANGED
@@ -128,6 +128,7 @@
128
  var keepSubmittedMessageOnReset = false;
129
  var autoFollowTimeline = true;
130
  var conversationStarted = false;
 
131
  var images = [];
132
  var COLLAPSED_STEP_HEIGHT = 220;
133
 
@@ -145,6 +146,8 @@
145
  var dropZone = document.getElementById("dropZone");
146
  var timeline = document.getElementById("timeline");
147
  var statusPill = document.getElementById("statusPill");
 
 
148
  var defaultPromptPlaceholder = promptInput.getAttribute("placeholder") || "Message ResearchHarness";
149
 
150
  function escapeHtml(value) {
@@ -215,6 +218,26 @@
215
  statusPill.className = "status " + (kind || "idle");
216
  }
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  function closeModelDropdown() {
219
  if (!modelDropdown || !modelDropdownButton) return;
220
  modelDropdown.classList.remove("open");
@@ -516,6 +539,8 @@
516
  setStatus("Connected", "idle");
517
  };
518
  ws.onclose = function () {
 
 
519
  clearAskRequest();
520
  setRunning(false, "Disconnected");
521
  setStatus("Disconnected", "error");
@@ -525,6 +550,8 @@
525
  if (message.type === "ready") {
526
  setStatus("Connected", "idle");
527
  } else if (message.type === "conversation_reset") {
 
 
528
  if (keepSubmittedMessageOnReset) {
529
  keepSubmittedMessageOnReset = false;
530
  ensureTimelineReady();
@@ -536,6 +563,8 @@
536
  } else if (message.type === "uploaded_images") {
537
  addEvent("runtime", "Uploaded images saved", "<pre>" + escapeHtml((message.paths || []).join("\n")) + "</pre>", []);
538
  } else if (message.type === "run_started") {
 
 
539
  setRunning(true, "Running");
540
  } else if (message.type === "interrupt_requested") {
541
  interrupting = true;
@@ -691,9 +720,18 @@
691
  clearTimeline();
692
  clearAskRequest();
693
  conversationStarted = false;
 
 
694
  setRunning(false, "Idle");
695
  }
696
  });
 
 
 
 
 
 
 
697
  attachBtn.addEventListener("click", function () {
698
  imageInput.click();
699
  });
 
128
  var keepSubmittedMessageOnReset = false;
129
  var autoFollowTimeline = true;
130
  var conversationStarted = false;
131
+ var downloadToken = "";
132
  var images = [];
133
  var COLLAPSED_STEP_HEIGHT = 220;
134
 
 
146
  var dropZone = document.getElementById("dropZone");
147
  var timeline = document.getElementById("timeline");
148
  var statusPill = document.getElementById("statusPill");
149
+ var workspaceMeta = document.getElementById("workspaceMeta");
150
+ var downloadWorkspaceBtn = document.getElementById("downloadWorkspaceBtn");
151
  var defaultPromptPlaceholder = promptInput.getAttribute("placeholder") || "Message ResearchHarness";
152
 
153
  function escapeHtml(value) {
 
218
  statusPill.className = "status " + (kind || "idle");
219
  }
220
 
221
+ function updateDownloadWorkspaceButton() {
222
+ if (!downloadWorkspaceBtn) return;
223
+ downloadWorkspaceBtn.disabled = !downloadToken;
224
+ downloadWorkspaceBtn.title = downloadToken
225
+ ? "Download files created or handled by the agent in this chat."
226
+ : "Run the agent first, then download the generated workspace files.";
227
+ }
228
+
229
+ function setDownloadToken(token) {
230
+ downloadToken = String(token || "");
231
+ updateDownloadWorkspaceButton();
232
+ }
233
+
234
+ function updateWorkspaceHint(hasWorkspace) {
235
+ if (!workspaceMeta) return;
236
+ workspaceMeta.textContent = hasWorkspace
237
+ ? "If you want to download files created or handled by the agent, click Download workspace.zip."
238
+ : "Managed temporary workspace. After a run, click Download workspace.zip to download files created or handled by the agent.";
239
+ }
240
+
241
  function closeModelDropdown() {
242
  if (!modelDropdown || !modelDropdownButton) return;
243
  modelDropdown.classList.remove("open");
 
539
  setStatus("Connected", "idle");
540
  };
541
  ws.onclose = function () {
542
+ setDownloadToken("");
543
+ updateWorkspaceHint(false);
544
  clearAskRequest();
545
  setRunning(false, "Disconnected");
546
  setStatus("Disconnected", "error");
 
550
  if (message.type === "ready") {
551
  setStatus("Connected", "idle");
552
  } else if (message.type === "conversation_reset") {
553
+ setDownloadToken("");
554
+ updateWorkspaceHint(false);
555
  if (keepSubmittedMessageOnReset) {
556
  keepSubmittedMessageOnReset = false;
557
  ensureTimelineReady();
 
563
  } else if (message.type === "uploaded_images") {
564
  addEvent("runtime", "Uploaded images saved", "<pre>" + escapeHtml((message.paths || []).join("\n")) + "</pre>", []);
565
  } else if (message.type === "run_started") {
566
+ setDownloadToken(message.download_token || "");
567
+ updateWorkspaceHint(Boolean(message.download_token));
568
  setRunning(true, "Running");
569
  } else if (message.type === "interrupt_requested") {
570
  interrupting = true;
 
720
  clearTimeline();
721
  clearAskRequest();
722
  conversationStarted = false;
723
+ setDownloadToken("");
724
+ updateWorkspaceHint(false);
725
  setRunning(false, "Idle");
726
  }
727
  });
728
+ if (downloadWorkspaceBtn) {
729
+ downloadWorkspaceBtn.addEventListener("click", function () {
730
+ if (!downloadToken) return;
731
+ window.location.href = "/api/workspace.zip?token=" + encodeURIComponent(downloadToken);
732
+ });
733
+ updateDownloadWorkspaceButton();
734
+ }
735
  attachBtn.addEventListener("click", function () {
736
  imageInput.click();
737
  });
frontend/static/index.html CHANGED
@@ -35,7 +35,10 @@
35
  </header>
36
 
37
  <section id="workspaceStrip" class="workspace-strip">
38
- <span id="workspaceMeta">Managed temporary workspace. Each chat uses an isolated runtime directory.</span>
 
 
 
39
  </section>
40
 
41
  <section id="timeline" class="messages">
 
35
  </header>
36
 
37
  <section id="workspaceStrip" class="workspace-strip">
38
+ <span id="workspaceMeta">Managed temporary workspace. After a run, click Download workspace.zip to download files created or handled by the agent.</span>
39
+ <button id="downloadWorkspaceBtn" class="download-workspace" type="button" disabled>
40
+ Download workspace.zip
41
+ </button>
42
  </section>
43
 
44
  <section id="timeline" class="messages">