Workspace.create misses workspace_members + silent General default — first-run experience broken #22

Closed
opened 2026-04-26 17:08:53 +00:00 by sameh-farouk · 0 comments
Member

Two coupled gaps in the operator's first-run experience

After running collab end-to-end through the proxy with a fresh DB, the deployer's first 60 seconds in the UI are needlessly broken in two ways. They're independent in source but coupled in user experience — the modal needs the membership row to actually work, and the membership row without the modal still leaves the operator stuck on "default 'General' workspace I didn't pick a name for."

Half 1 — workspace.create doesn't add the creator to workspace_members

handlers/workspace.rs::create does:

  1. Permission check (workspace.create)
  2. INSERT into workspaces
  3. UPDATE the data JSON
  4. log_activity_or_warn
  5. return the workspace object

No INSERT into workspace_members. Result: the workspace exists, the creator passes the perm check, but every per-workspace operation that gates on membership fails immediately:

$ workspace.create({name:"Acme"}) → ok, returns id=1
$ canvas.create({workspace_id:1, title:"Test"})
  → -32002 PermissionDenied: Not a member of workspace 1

For comparison: channel.create and canvas.create already auto-add the creator to their respective scope tables (channel_members with role=admin, canvas_collaborators with role=owner). workspace.create was the only one missing the equivalent INSERT.

Half 2 — chat-app.js silently auto-creates a "General" workspace

pickUser had:

if (state.workspaces.length === 0) {
    const ws = await rpc('workspace.create', { name: 'General', description: 'Default workspace' });
    state.workspaces = [ws];
}

The operator opens the chat for the first time and a workspace named "General" appears with no choice, no naming, no awareness that this just happened. They either rename it (annoying friction) or live with it (looks like off-the-shelf product). No modern self-hosted product does this — Slack, Discord, Mattermost, Linear, Notion all ask you to name your workspace as the first onboarding step.

Plus: init() was firing-and-forgetting workspace.list, so pickUser could see stale state.workspaces=[] even when the server had workspaces — creating spurious "General" workspaces and confusing the operator.

Why coupled

If we fix the membership row but keep the silent "General" auto-create, the operator never sees a setup screen and the workspace name they end up with isn't theirs. If we add a setup modal but leave the membership row missing, the modal succeeds but the chat is immediately unusable (canvas.create fails). Both halves are required for the first-run experience to actually work.

Fix shape

Server: workspace.create auto-adds the creator

// After the workspace insert, before activity log:
if let Some(caller) = input.caller_id {
    db.execute(
        "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, data)
         VALUES (?1, ?2, '{}')",
        rusqlite::params![id as i64, caller as i64],
    ).ok(); // log + ignore — workspace is still good even if this fails
}

Wrapped in if let Some(caller) so dev-mode unauth (caller_id=None) silently skips. canvas.create's membership check is also dev-mode-bypassed, so no functional break in dev.

Client: first-run setup modal

Replace the silent auto-create with a forced modal that asks for workspace name + first channel name. Slack-pattern onboarding.

Polish details (all in chat-app.js::showFirstRunSetup):

  • Backdrop rgba(0,0,0,0.85) + backdrop-filter: blur(4px) so the chat layout (sidebar avatar) doesn't bleed through
  • Submit button starts disabled + grey; enables only when both fields validate
  • Live per-field validation: workspace name required, channel name slug-checked client-side (mirrors server's validate_channel_slug)
  • Workspace name pre-filled with ${user.name}'s Workspace (selected on focus so it's overwriteable)
  • Inline animated spinner during submit + lock both inputs (visual motion = "still working")
  • Per-field hint/error line + form-level error span
  • Tighter rhythm: gap:14px, narrower max-width:420px since it's only 2 fields
  • Keyboard: Enter advances field-to-field, Cmd/Ctrl+Enter submits, Esc clears form-level error
  • "Switch user" link visible only when state.users.length > 1 (dev mode); proxy mode locks the user
  • DOM built via createElement throughout — only user-derived value (user.name) set via textContent, no innerHTML on user data

Also: init()'s proxy-mode branch awaits workspace.list so the empty-check in pickUser is accurate.

Behaviour after the fix

Scenario Today After fix
Proxy mode, deployer's first request, fresh DB silent "General" workspace appears first-run modal asks for name; deployer types theirs
Dev mode (picker), fresh DB after delete silent "General" appears same first-run modal; "Switch user" link visible
Joining user (proxy mode, populated DB) unchanged — sees existing workspace unchanged — sees existing workspace, modal doesn't fire
Workspace creator tries canvas.create Not a member of workspace N succeeds — membership row was added by workspace.create

PR

Fix in feat/workspace-creator-membership-and-first-run-ui. ~310 LOC across handlers/workspace.rs (+20 server) and chat-app.js (+290 client, mostly the polished modal). No schema change.

## Two coupled gaps in the operator's first-run experience After running collab end-to-end through the proxy with a fresh DB, the deployer's first 60 seconds in the UI are needlessly broken in two ways. They're independent in source but coupled in user experience — the modal needs the membership row to actually work, and the membership row without the modal still leaves the operator stuck on "default 'General' workspace I didn't pick a name for." ## Half 1 — `workspace.create` doesn't add the creator to `workspace_members` `handlers/workspace.rs::create` does: 1. Permission check (`workspace.create`) 2. INSERT into `workspaces` 3. UPDATE the `data` JSON 4. log_activity_or_warn 5. return the workspace object **No INSERT into `workspace_members`.** Result: the workspace exists, the creator passes the perm check, but every per-workspace operation that gates on membership fails immediately: ``` $ workspace.create({name:"Acme"}) → ok, returns id=1 $ canvas.create({workspace_id:1, title:"Test"}) → -32002 PermissionDenied: Not a member of workspace 1 ``` For comparison: `channel.create` and `canvas.create` already auto-add the creator to their respective scope tables (`channel_members` with `role=admin`, `canvas_collaborators` with `role=owner`). `workspace.create` was the only one missing the equivalent INSERT. ## Half 2 — chat-app.js silently auto-creates a "General" workspace `pickUser` had: ```js if (state.workspaces.length === 0) { const ws = await rpc('workspace.create', { name: 'General', description: 'Default workspace' }); state.workspaces = [ws]; } ``` The operator opens the chat for the first time and a workspace named "General" appears with no choice, no naming, no awareness that this just happened. They either rename it (annoying friction) or live with it (looks like off-the-shelf product). No modern self-hosted product does this — Slack, Discord, Mattermost, Linear, Notion all ask you to name your workspace as the first onboarding step. Plus: `init()` was firing-and-forgetting `workspace.list`, so `pickUser` could see stale `state.workspaces=[]` even when the server had workspaces — creating spurious "General" workspaces and confusing the operator. ## Why coupled If we fix the membership row but keep the silent "General" auto-create, the operator never sees a setup screen and the workspace name they end up with isn't theirs. If we add a setup modal but leave the membership row missing, the modal succeeds but the chat is immediately unusable (canvas.create fails). Both halves are required for the first-run experience to actually work. ## Fix shape ### Server: `workspace.create` auto-adds the creator ```rust // After the workspace insert, before activity log: if let Some(caller) = input.caller_id { db.execute( "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, data) VALUES (?1, ?2, '{}')", rusqlite::params![id as i64, caller as i64], ).ok(); // log + ignore — workspace is still good even if this fails } ``` Wrapped in `if let Some(caller)` so dev-mode unauth (caller_id=None) silently skips. canvas.create's membership check is also dev-mode-bypassed, so no functional break in dev. ### Client: first-run setup modal Replace the silent auto-create with a forced modal that asks for workspace name + first channel name. Slack-pattern onboarding. Polish details (all in `chat-app.js::showFirstRunSetup`): - Backdrop `rgba(0,0,0,0.85)` + `backdrop-filter: blur(4px)` so the chat layout (sidebar avatar) doesn't bleed through - Submit button starts disabled + grey; enables only when both fields validate - Live per-field validation: workspace name required, channel name slug-checked client-side (mirrors server's `validate_channel_slug`) - Workspace name pre-filled with `${user.name}'s Workspace` (selected on focus so it's overwriteable) - Inline animated spinner during submit + lock both inputs (visual motion = "still working") - Per-field hint/error line + form-level error span - Tighter rhythm: `gap:14px`, narrower `max-width:420px` since it's only 2 fields - Keyboard: Enter advances field-to-field, Cmd/Ctrl+Enter submits, Esc clears form-level error - "Switch user" link visible only when `state.users.length > 1` (dev mode); proxy mode locks the user - DOM built via `createElement` throughout — only user-derived value (`user.name`) set via `textContent`, no innerHTML on user data Also: `init()`'s proxy-mode branch awaits `workspace.list` so the empty-check in `pickUser` is accurate. ## Behaviour after the fix | Scenario | Today | After fix | |---|---|---| | Proxy mode, deployer's first request, fresh DB | silent "General" workspace appears | first-run modal asks for name; deployer types theirs | | Dev mode (picker), fresh DB after delete | silent "General" appears | same first-run modal; "Switch user" link visible | | Joining user (proxy mode, populated DB) | unchanged — sees existing workspace | unchanged — sees existing workspace, modal doesn't fire | | Workspace creator tries `canvas.create` | `Not a member of workspace N` | succeeds — membership row was added by `workspace.create` | ## PR Fix in `feat/workspace-creator-membership-and-first-run-ui`. ~310 LOC across `handlers/workspace.rs` (+20 server) and `chat-app.js` (+290 client, mostly the polished modal). No schema change.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_collab#22
No description provided.