diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7175214..bd23ba0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,63 @@
# Changelog
+## [0.37.18.0] — 2026-03-24
+
+### Summary
+
+Debug/Model surface rebuild + workspace bridge. Debug modal converted from
+673 lines of imperative DOM JS to a Preact component tree (CR P2-5). User
+default workspace auto-created on first access. Tool outputs auto-save file
+refs linked to assistant messages. "Save to workspace" action on chat code
+blocks. Model selector shows provider health dots. Provider "Test Connection"
+buttons added to both BYOK and admin UIs.
+
+### Added
+
+- **Debug modal Preact rebuild:** Engine singleton (`debugEngine`) with
+ subscribe() pattern for reactive updates. Component tree: ConsoleTab,
+ NetworkTab, StateTab, ReplTab, DebugBadge. `sw-debug.css` extracted from
+ `modals.css`. `debug.js` reduced from 673 to ~40 lines (thin bootstrap).
+ `repl.js` absorbed into `repl-tab.js`. (`src/js/sw/components/debug/`)
+- **Default user workspace:** `GET /workspaces/default` auto-creates a
+ "My Files" workspace per user on first access. Idempotent. Route wired
+ before `:id` param. SDK: `sw.api.workspaces.getDefault()`.
+ (`server/handlers/workspaces.go`, `server/main.go`)
+- **tool_output auto-save:** When `workspace_write` or `workspace_patch`
+ tools succeed during completion, a file ref with `origin=tool_output` is
+ recorded in the `files` table, linked to the assistant message via
+ `message_id`. (`server/handlers/tool_loop.go`, `completion.go`)
+- **Origin filter on channel files:** `GET /channels/:id/files?origin=`
+ query param. SDK: `sw.api.channels.files(id, {origin})`.
+ (`server/handlers/files.go`)
+- **Save-to-Workspace bridge:** Save button on assistant messages with code
+ blocks. Saves to user's default workspace with auto-generated filenames.
+ `guessExtension()` utility (37 language mappings). (`message-actions.js`,
+ `message-bubble.js`, `code-block.js`)
+- **Model selector health dots:** Provider health status (green/yellow/red)
+ shown next to model names in chat selector. Admin-only, graceful degrade
+ for non-admin. (`model-selector.js`)
+- **Provider test buttons:** "Test Connection" in Settings BYOK and "Test"
+ in Admin providers. Calls `fetchModels()` and shows success/failure toast.
+ (`settings/providers.js`, `admin/providers.js`)
+
+### Changed
+
+- `modals.css`: Debug-specific rules moved to `sw-debug.css` (~22 rules).
+- `base.html`: 46-line debug modal HTML block replaced by `
`.
+- `sw.js`: Service worker cache list updated (repl.js removed, sw-debug.css added).
+- `LoopResult` struct: new `FileRefs` field for tool file output tracking.
+- `channels.files` SDK method: now accepts optional opts for query params.
+
+### Removed
+
+- `src/js/repl.js` (527 lines) — absorbed into Preact `repl-tab.js`.
+
+### ICD / OpenAPI / SDK Test Runner
+
+- ICD: `GET /workspaces/default` documented; tool_output creation behavior updated.
+- OpenAPI: `/api/v1/workspaces/default` endpoint added.
+- SDK runner: 2 workspace tests (getDefault + idempotent), 1 channel files origin filter test.
+
## [0.37.17.0] — 2026-03-24
### Summary
diff --git a/docs/ICD/channels.md b/docs/ICD/channels.md
index 895fafc..0501128 100644
--- a/docs/ICD/channels.md
+++ b/docs/ICD/channels.md
@@ -454,10 +454,11 @@ Content-Type: multipart/form-data
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
-**Create (tool output):** Tool-generated files are created internally
-by the tool execution loop. There is no public REST endpoint for
-tool output creation — the completion handler writes files directly
-via the store layer.
+**Create (tool output):** When `workspace_write` or `workspace_patch`
+tools succeed during a completion, the handler auto-records a file
+reference with `origin: "tool_output"` linked to the assistant message
+(v0.37.18). No public endpoint — created internally by the completion
+handler via the store layer.
**List by channel:**
diff --git a/docs/ICD/workspaces.md b/docs/ICD/workspaces.md
index c414173..d7cae0e 100644
--- a/docs/ICD/workspaces.md
+++ b/docs/ICD/workspaces.md
@@ -16,6 +16,7 @@ owning team/project/channel).
```
GET /workspaces → { "data": [...] }
POST /workspaces ← { "name", "owner_type", "owner_id" }
+GET /workspaces/default → workspace object (v0.37.18)
GET /workspaces/:id → workspace object
PATCH /workspaces/:id ← partial update
DELETE /workspaces/:id
@@ -25,6 +26,15 @@ DELETE /workspaces/:id
workspaces plus those owned by the user's teams. All `:id` endpoints
verify the caller can access the workspace's owner entity.
+#### `GET /workspaces/default` (v0.37.18)
+
+Returns the current user's personal workspace, creating it on first
+access. The workspace is named "My Files" and owned by the current user.
+Idempotent — safe to call on every page load.
+
+**Response:** 200 with workspace object (same shape as `GET /workspaces/:id`).
+Includes `file_count` and `total_bytes` stats.
+
Workspace object:
```json
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0324110..e40638e 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -49,7 +49,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
│ v0.37.3 SDK ✅ │
│ v0.37.4 Shell ✅ │
│ v0.37.5–16 Surfaces ✅ │
- │ v0.37.17–19 Surfaces │
+ │ v0.37.17–18 Surfaces ✅ │
+ │ v0.37.19 Tag │
│ │ │
│ v0.38.x Workflow │
│ Product Maturity │
@@ -193,8 +194,8 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
| v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue |
| v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link |
-| v0.37.17 | Workspaces | Workspace-first project file storage, tree browser UI, archive upload/download |
-| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
+| v0.37.17 | Workspaces ✅ | Workspace-first project file storage, tree browser UI, archive upload/download |
+| v0.37.18 | Debug / model surface ✅ | Debug modal Preact rebuild (CR P2-5), default workspace, tool_output auto-save, save-to-workspace bridge, model/provider polish |
| v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) |
**v0.37.14 — Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0–.22):
@@ -238,11 +239,13 @@ Git integration (clone, pull, branch tracking) is optional/post-MVP.
- [x] Archive download: zip bundle of project files
- [x] Storage quota enforcement (`max_bytes` on workspace)
-**v0.37.18 — Debug + Model Surface:**
+**v0.37.18 — Debug + Model Surface ✅:**
-Debug modal Preact rebuild, model configuration UI, provider diagnostics.
-Also: user default workspace, `origin=tool_output` auto-save in completion
-handler, "Save to workspace" bridge action on chat files/artifacts.
+Debug modal Preact rebuild (673-line imperative → component tree, 8 files).
+User default workspace (auto-create "My Files" on first access). tool_output
+auto-save (file refs linked to assistant messages). Save-to-workspace bridge
+(save code blocks to workspace). Model selector health dots. Provider test
+buttons (BYOK + admin). repl.js absorbed into Preact. sw-debug.css extracted.
**v0.37.19 — Tag ("UI Complete"):**
diff --git a/packages/sdk-test-runner/js/domains/channels.js b/packages/sdk-test-runner/js/domains/channels.js
index 4341ec5..5c217c3 100644
--- a/packages/sdk-test-runner/js/domains/channels.js
+++ b/packages/sdk-test-runner/js/domains/channels.js
@@ -131,6 +131,16 @@
}
});
+ // ── Files with origin filter (v0.37.18) ──
+
+ await T.test('channels', 'files', 'sw.api.channels.files(id, {origin})', {
+ sdk: function () { return sw.api.channels.files(channelId, { origin: 'tool_output' }); },
+ raw: { method: 'GET', path: '/channels/' + channelId + '/files?origin=tool_output' },
+ validate: function (r) {
+ T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array');
+ }
+ });
+
// ── Participants ──
await T.test('channels', 'participants', 'sw.api.channels.participants(id)', {
diff --git a/packages/sdk-test-runner/js/domains/workspaces.js b/packages/sdk-test-runner/js/domains/workspaces.js
index 6f2afe2..87652d5 100644
--- a/packages/sdk-test-runner/js/domains/workspaces.js
+++ b/packages/sdk-test-runner/js/domains/workspaces.js
@@ -24,6 +24,31 @@
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
});
+ // ── GetDefault (v0.37.18) ──
+
+ var defaultWsId = null;
+
+ await T.test('workspaces', 'getDefault', 'sw.api.workspaces.getDefault()', {
+ sdk: function () { return sw.api.workspaces.getDefault(); },
+ raw: { method: 'GET', path: '/workspaces/default' },
+ validate: function (r) {
+ T.assertShape(r, T.S.workspace, 'workspace');
+ T.assert(r.owner_type === 'user', 'expected owner_type=user');
+ T.assert(r.owner_id === userId, 'expected owner_id=current user');
+ defaultWsId = r.id;
+ }
+ });
+
+ // ── GetDefault idempotent ──
+
+ await T.test('workspaces', 'getDefault', 'sw.api.workspaces.getDefault() idempotent', {
+ sdk: function () { return sw.api.workspaces.getDefault(); },
+ raw: { method: 'GET', path: '/workspaces/default' },
+ validate: function (r) {
+ T.assert(r.id === defaultWsId, 'expected same workspace on second call');
+ }
+ });
+
// ── Create ──
await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', {
diff --git a/packages/sdk-test-runner/js/fixtures.js b/packages/sdk-test-runner/js/fixtures.js
index adba0e6..cafbb79 100644
--- a/packages/sdk-test-runner/js/fixtures.js
+++ b/packages/sdk-test-runner/js/fixtures.js
@@ -84,6 +84,17 @@
console.log('[SDKR:fixtures] ' + msg);
}
+ // ── Resolve default endpoint for selected provider ──
+ var defaultEndpoint = '';
+ if (selectedProvider) {
+ try {
+ var typesResp = await T.raw.get('/admin/provider-types');
+ var types = typesResp.types || typesResp.data || typesResp || [];
+ var match = types.find ? types.find(function (t) { return t.id === selectedProvider; }) : null;
+ if (match) defaultEndpoint = match.default_endpoint || '';
+ } catch (_) { /* best effort — endpoint will be required in payload */ }
+ }
+
// Check if any configs already exist
try {
var configs = await T.raw.get('/admin/configs');
@@ -103,6 +114,7 @@
var configData = {
name: 'sdkr-fixture-' + selectedProvider,
provider: selectedProvider,
+ endpoint: defaultEndpoint,
api_key: apiKey,
scope: 'global'
};
@@ -129,6 +141,7 @@
var configData2 = {
name: 'sdkr-fixture-' + selectedProvider,
provider: selectedProvider,
+ endpoint: defaultEndpoint,
scope: 'global'
};
var created2 = await T.raw.post('/admin/configs', configData2);
diff --git a/server/handlers/completion.go b/server/handlers/completion.go
index 4581d81..f5c1ddf 100644
--- a/server/handlers/completion.go
+++ b/server/handlers/completion.go
@@ -963,6 +963,9 @@ func (h *CompletionHandler) streamCompletion(
log.Printf("Failed to persist assistant message: %v", err)
}
+ // Record tool_output file refs (v0.37.18)
+ h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, asstMsgID)
+
// Broadcast assistant message to other channel participants
if h.hub != nil && asstMsgID != "" {
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
@@ -1049,10 +1052,14 @@ func (h *CompletionHandler) syncCompletion(
finalContent := result.Content
// Persist assistant response
- if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
+ syncAsstMsgID, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID)
+ if err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
+ // Record tool_output file refs (v0.37.18)
+ h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, syncAsstMsgID)
+
// AI-to-AI chaining
go func() {
defer func() {
@@ -1770,6 +1777,31 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
// ── Message Persistence ─────────────────────
+// recordToolFileRefs creates file records with origin=tool_output for workspace
+// files produced by tools during a completion. Links each file to the assistant
+// message so the UI can show "files generated by this response".
+func (h *CompletionHandler) recordToolFileRefs(ctx context.Context, refs []FileRef, channelID, userID, asstMsgID string) {
+ if len(refs) == 0 || asstMsgID == "" {
+ return
+ }
+ for _, ref := range refs {
+ f := &models.File{
+ ChannelID: channelID,
+ UserID: userID,
+ Origin: models.FileOriginToolOutput,
+ Filename: ref.Path,
+ ContentType: "application/octet-stream",
+ DisplayHint: "download",
+ }
+ if asstMsgID != "" {
+ f.MessageID = &asstMsgID
+ }
+ if err := h.stores.Files.Create(ctx, f); err != nil {
+ log.Printf("[file_ref] Failed to record tool_output file ref %s: %v", ref.Path, err)
+ }
+ }
+}
+
// persistMessage inserts a message into the tree, updates the cursor, and
// returns the new message's ID.
//
diff --git a/server/handlers/files.go b/server/handlers/files.go
index 9109089..e78e263 100644
--- a/server/handlers/files.go
+++ b/server/handlers/files.go
@@ -271,7 +271,8 @@ func (h *FileHandler) ListByChannel(c *gin.Context) {
return
}
- files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "")
+ origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system)
+ files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
diff --git a/server/handlers/tool_loop.go b/server/handlers/tool_loop.go
index 05f65db..ac90ae5 100644
--- a/server/handlers/tool_loop.go
+++ b/server/handlers/tool_loop.go
@@ -42,9 +42,16 @@ const defaultMaxRounds = 10
// LoopResult holds the accumulated output from a completion with tool
// execution. Callers are responsible for persistence.
+// FileRef tracks a file created by a tool for auto-linking to the assistant message.
+type FileRef struct {
+ WorkspaceID string `json:"workspace_id"`
+ Path string `json:"path"`
+}
+
type LoopResult struct {
Content string
ToolActivity []map[string]interface{}
+ FileRefs []FileRef // v0.37.18: workspace files created by tools
InputTokens int
OutputTokens int
CacheCreationTokens int
@@ -210,6 +217,11 @@ func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResul
// Emit workspace.file.changed for live editor updates (v0.21.5)
emitWorkspaceEvent(lcfg, call, toolResult)
+ // Collect file refs for tool_output auto-save (v0.37.18)
+ if ref := collectFileRef(lcfg, call, toolResult); ref != nil {
+ result.FileRefs = append(result.FileRefs, *ref)
+ }
+
// Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
@@ -574,6 +586,26 @@ func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolR
}
}
+// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds.
+func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef {
+ if result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
+ return nil
+ }
+ if call.Name != "workspace_write" && call.Name != "workspace_patch" {
+ return nil
+ }
+ var toolArgs struct {
+ Path string `json:"path"`
+ }
+ if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
+ return &FileRef{
+ WorkspaceID: lcfg.ExecCtx.WorkspaceID,
+ Path: toolArgs.Path,
+ }
+ }
+ return nil
+}
+
// ── Sink Implementations ───────────────────────
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go
index 0918066..8cc1cfc 100644
--- a/server/handlers/workspaces.go
+++ b/server/handlers/workspaces.go
@@ -85,6 +85,55 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, w)
}
+// GetDefault returns the current user's personal workspace, creating it on first access.
+// The personal workspace is the first user-owned workspace (by created_at) or a new
+// "My Files" workspace if none exists. Idempotent — safe to call on every page load.
+func (h *WorkspaceHandler) GetDefault(c *gin.Context) {
+ userID := getUserID(c)
+ ctx := c.Request.Context()
+
+ w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID)
+ if err == nil {
+ // Enrich with stats
+ stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID)
+ if stats != nil {
+ w.FileCount = stats.FileCount
+ w.TotalBytes = stats.TotalBytes
+ }
+ c.JSON(http.StatusOK, w)
+ return
+ }
+ if err != sql.ErrNoRows {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"})
+ log.Printf("workspace getDefault: %v", err)
+ return
+ }
+
+ // Auto-create personal workspace
+ w = &models.Workspace{
+ OwnerType: models.WorkspaceOwnerUser,
+ OwnerID: userID,
+ Name: "My Files",
+ Status: models.WorkspaceStatusActive,
+ }
+ w.ID = store.NewID()
+ w.RootPath = "workspaces/" + w.ID
+
+ if err := h.stores.Workspaces.Create(ctx, w); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"})
+ log.Printf("workspace getDefault create: %v", err)
+ return
+ }
+
+ if err := h.wfs.CreateDir(w); err != nil {
+ log.Printf("workspace getDefault mkdir: %v", err)
+ // Workspace record exists but directory failed — still return the workspace.
+ // Directory will be created on first file upload.
+ }
+
+ c.JSON(http.StatusOK, w)
+}
+
func (h *WorkspaceHandler) Get(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
diff --git a/server/main.go b/server/main.go
index 6c3e9ae..5fce905 100644
--- a/server/main.go
+++ b/server/main.go
@@ -919,6 +919,7 @@ func main() {
wsH := handlers.NewWorkspaceHandler(stores, wfs)
protected.POST("/workspaces", wsH.Create)
protected.GET("/workspaces", wsH.List)
+ protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param
protected.GET("/workspaces/:id", wsH.Get)
protected.PATCH("/workspaces/:id", wsH.Update)
protected.DELETE("/workspaces/:id", wsH.Delete)
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 3b46b4b..9d4706a 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -25,6 +25,7 @@
+
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
{{if eq .Surface "projects"}}{{template "css-projects" .}}{{end}}
@@ -142,55 +143,9 @@
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
- {{/* ── Debug Log Modal (all surfaces) ───── */}}
-