Skip to content

feat: add workspace labels - #231

Merged
centdix merged 2 commits into
mainfrom
edit-workspace-labels
May 12, 2026
Merged

feat: add workspace labels#231
centdix merged 2 commits into
mainfrom
edit-workspace-labels

Conversation

@centdix

@centdix centdix commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Add editable workspace labels so users can give worktrees a human-readable name when auto-generated branch names are unclear. Labels are metadata only: branch names, paths, tmux windows, and command identifiers remain unchanged.

Changes

  • Add a typed PUT /api/worktrees/:name/label API and persist labels in existing worktree metadata.
  • Include labels in backend snapshots, frontend shared types, API mapping, sidebar search, and selected-worktree display.
  • Add a pencil action in the worktree header that opens a label dialog; labeled worktrees show the label first and the real branch in small text below.
  • Add CLI parity with webmux label <branch> <label> and webmux label <branch> --clear, plus label-aware webmux list output/search and completions.
  • Add backend, frontend, API-contract, and CLI tests for label persistence, rendering, search, and command parsing/dispatch.

Test plan

  • bun run --cwd backend check
  • bun run --cwd frontend check
  • bash scripts/run-with-isolated-tmux.sh bun run --cwd backend test
  • bun run --cwd frontend test
  • bun test packages/api-contract/src
  • bun test bin/src
  • git diff --check
  • Browser smoke check: opened the label dialog from the worktree header and verified no console errors

Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
webmux fa33363 Commit Preview URL

Branch Preview URL
May 12 2026, 01:44 PM

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Claude finished @centdix's task in 4m 12s —— View job


Code review for PR #231 — workspace labels

Solid feature: the API contract, types, mapping, snapshot, frontend rendering, CLI parity, and tests are all wired together coherently and the surface-by-surface coverage is good. A few concerns to address before merging — one is non-trivial.


Main concern

LifecycleService.setWorktreeLabel does much more than a metadata update. It calls refreshManagedArtifacts for managed worktrees (rewrites runtime.env / control.env) and — more concerningly — initializeUnmanagedWorktree for unmanaged ones. That second branch allocates ports, picks a default profile/agent, and creates managed env files, so a webmux label <unmanaged-branch> "foo" silently promotes the worktree to managed.

That contradicts the PR description ("Labels are metadata only: branch names, paths, tmux windows, and command identifiers remain unchanged") and isn't covered by a test. setWorktreeArchived (a few methods up) is the right shape to emulate: resolve, mutate, reconcile — no env churn.

See inline comment on lifecycle-service.ts for a suggested rewrite.

Fix this →


Other findings

  • WorktreeLabelDialog.svelte uses $effect to sync initialLabel into a $state. Per frontend/CLAUDE.md ($derived for values, $effect for side-effects only), this should be let label = $state(initialLabel ?? ""). The current pattern can also be raced by a websocket snapshot update mid-typing (the effect would clobber user input). Since the dialog is already remounted on each open, one-time initialization is enough. Inline suggestion provided.
  • parseLabelCommandArgs concatenates --label text with trailing positionals. webmux label feat/foo --label "Search" extra yields "Search extra". The help text presents --label <text> and <label> as equivalent forms, not mixable. Recommend erroring on the combination, or ignoring positional words when --label was passed.
  • WorktreeLabelDialog Save UX: Save is enabled when the input is empty and there was no prior label; submitting performs a no-op round-trip. Consider disabling Save when the trimmed input equals the current initialLabel ?? "". Also worth confirming BaseDialog handles Escape — if not, add it here.
  • adapters/fs.ts normalizeWorktreeMeta lost its reference-equality short-circuit; every read now allocates a fresh meta object. Trivial perf, easy to restore.

Nits

  • No dedicated test for WorktreeLabelDialog.svelte. App.test.ts covers the happy path, but a small dialog-level test for Clear-button disabled-when-no-initial-label and submit/cancel semantics would be cheap insurance.
  • backend/src/services/lifecycle-service.ts:62-69MAX_WORKTREE_LABEL_LENGTH = 80 is duplicated against the zod .max(80) in packages/api-contract/src/schemas.ts:121. The contract schema already rejects oversized payloads at the boundary, so the lifecycle-level check is double-wallpaper. Either move the constant into a shared place or rely on the schema and drop the secondary check.
  • frontend/src/lib/WorktreeLabelDialog.svelte:18onconfirm: (label: string) => void. Submitting unchanged whitespace passes raw text; consider label.trim() here so the parent doesn't make a request that only the backend distinguishes from "no change".

What's good

  • API contract update is clean and the SetWorktreeLabel{Request,Response}Schema placement matches the surrounding pattern.
  • ProjectWorktreeSnapshotSchema.label: z.string().nullable() as required, with reconciliation/snapshot mapping meta?.label ?? null — no undefined leaking to the client.
  • Frontend displayName / displayBranch derivation in TopBar.svelte and WorktreeList.svelte is symmetric and accessible (proper title attributes on truncated text).
  • CLI parity (label subcommand, completions for bash + zsh, label-aware list output, search-text inclusion) is thorough and well-tested.
  • Snapshot, reconciliation, lifecycle, fs adapter, CLI, and frontend tests all touch the new field — strong coverage.

Todos

  • Read repo conventions (CLAUDE.md, backend/CLAUDE.md, frontend/CLAUDE.md)
  • Review API contract (packages/api-contract)
  • Review backend changes (model, storage, lifecycle, server, snapshot)
  • Review frontend changes (TopBar, dialog, list, App)
  • Review CLI changes (webmux, worktree-commands, completions)
  • Review tests
  • Post review feedback (inline + summary)
    · branch: edit-workspace-labels

Comment on lines +358 to +372
async setWorktreeLabel(branch: string, label: string | null): Promise<{ label: string | null }> {
try {
const normalizedLabel = normalizeWorktreeLabel(label);
const resolved = await this.resolveExistingWorktree(branch);
const initialized = resolved.meta
? await this.refreshManagedArtifacts(resolved)
: await this.initializeUnmanagedWorktree(resolved);
const nextMeta = this.withUpdatedLabel(initialized.meta, normalizedLabel);
await writeWorktreeMeta(resolved.gitDir, nextMeta);
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
return { label: normalizedLabel };
} catch (error) {
throw this.wrapOperationError(error);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unwanted side effects for a "metadata only" operation.

The PR description says labels are metadata only ("branch names, paths, tmux windows, and command identifiers remain unchanged"), but this method calls either refreshManagedArtifacts (rewrites runtime.env and control.env) or, more concerningly, initializeUnmanagedWorktree — which allocates ports, picks a default profile/agent, and writes env files. So labeling a previously‑unmanaged worktree silently promotes it to managed.

Compare with setWorktreeArchived (line 346), which just resolves the worktree and toggles archive state — no env churn.

For label updates, the operation should be limited to:

  1. readWorktreeMeta(gitDir) (or fail/no-op if absent)
  2. writeWorktreeMeta(gitDir, { ...meta, label })
  3. reconcile(..., { force: true })

Suggested:

async setWorktreeLabel(branch: string, label: string | null): Promise<{ label: string | null }> {
  try {
    const normalizedLabel = normalizeWorktreeLabel(label);
    const resolved = await this.resolveExistingWorktree(branch);
    if (!resolved.meta) {
      throw new LifecycleError(`Worktree ${branch} has no managed metadata to label`, 409);
    }
    const nextMeta = this.withUpdatedLabel(resolved.meta, normalizedLabel);
    await writeWorktreeMeta(resolved.gitDir, nextMeta);
    await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
    return { label: normalizedLabel };
  } catch (error) {
    throw this.wrapOperationError(error);
  }
}

If labeling unmanaged worktrees is intentionally supported, that decision deserves to be explicit (and probably documented) rather than a side effect of reusing initializeUnmanagedWorktree. There's no test covering the unmanaged case today, so the silent promotion is unguarded.

Comment on lines +23 to +28
let label = $state("");
let inputEl = $state<HTMLInputElement | null>(null);

$effect(() => {
label = initialLabel ?? "";
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per frontend/CLAUDE.md ("Prefer $derived over $effect for computed values — effects are for side-effects only"), this prop→state sync is the wrong tool.

It's also a subtle bug: if a websocket snapshot updates labelWorktree.label while the user is mid-typing in the dialog, initialLabel changes and the effect overwrites the user's input. Since the dialog already remounts on each open (gated by {#if labelBranch && labelWorktree} in App.svelte:1252), one-time initialization is enough:

Suggested change
let label = $state("");
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
label = initialLabel ?? "";
});
let label = $state(initialLabel ?? "");
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
const currentInput = inputEl;
if (!currentInput) return;
queueMicrotask(() => currentInput.focus());
});

Comment on lines +331 to +347
if (arg === "--label" || arg.startsWith("--label=")) {
const { value, nextIndex } = readOptionValue(args, index, "--label");
labelParts.push(value);
index = nextIndex;
continue;
}

if (arg.startsWith("-")) {
throw new CommandUsageError(`Unknown option: ${arg}`);
}

if (!branch) {
branch = arg;
continue;
}

labelParts.push(arg);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected concatenation of --label and trailing positionals.

Because --label pushes into the same labelParts array that trailing positionals also push into, webmux label feat/foo --label "Search" extra silently produces label "Search extra". Most CLIs treat --label <text> as exclusive of positional label text.

Consider either (a) erroring if both --label and positional label parts are supplied, or (b) ignoring positional words when --label was used. The current behavior is also undocumented — the help text describes --label <text> and <label> as if they were two ways to spell the same thing, not as mixable.

This is a minor UX rough edge — not a blocker — but worth tightening since the rest of the CLI parsing in this file is otherwise quite strict.

Comment on lines 225 to 234
function normalizeWorktreeMeta(meta: WorktreeMeta): WorktreeMeta {
const conversation = normalizeConversationMeta(meta.conversation);
return conversation === meta.conversation
? meta
: {
...meta,
conversation,
};
const { label, conversation: rawConversation, ...rest } = meta;
const conversation = normalizeConversationMeta(rawConversation);
const normalizedLabel = normalizeOptionalString(label);
return {
...rest,
...(normalizedLabel ? { label: normalizedLabel } : {}),
...(conversation !== undefined ? { conversation } : {}),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this no longer short-circuits when the meta is already normalized. Before, when conversation === meta.conversation we returned the original meta reference; now we always build a new object (even for reads of well-formed meta files). Not a correctness issue, just a small allocation regression. If you want to preserve the optimization:

function normalizeWorktreeMeta(meta: WorktreeMeta): WorktreeMeta {
  const conversation = normalizeConversationMeta(meta.conversation);
  const normalizedLabel = normalizeOptionalString(meta.label);
  if (conversation === meta.conversation && normalizedLabel === meta.label) {
    return meta;
  }
  const { label: _label, conversation: _conv, ...rest } = meta;
  return {
    ...rest,
    ...(normalizedLabel ? { label: normalizedLabel } : {}),
    ...(conversation !== undefined ? { conversation } : {}),
  };
}

Comment on lines +53 to +60
<div class="flex justify-between gap-2">
<Btn type="button" onclick={onclear} disabled={loading || !initialLabel}>Clear</Btn>
<div class="flex justify-end gap-2">
<Btn type="button" onclick={oncancel} disabled={loading}>Cancel</Btn>
<Btn type="submit" variant="cta" class="flex items-center gap-1.5" disabled={loading}
>{#if loading}<span class="spinner"></span>{/if} Save</Btn
>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small UX gaps worth considering:

  1. Save is enabled when the input is empty and there was no initial label. That submits "", which the backend normalizes to null and silently no-ops — a wasted round-trip with a confusing "label cleared" feel. Disabling Save when the trimmed input is empty and !initialLabel would avoid this. Alternatively, disable Save when the trimmed input equals the current initialLabel ?? "" so unchanged submits don't fire either.
  2. No keyboard cancel. BaseDialog may already handle Escape — if it doesn't, this dialog should explicitly so the modal feels consistent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@centdix
centdix marked this pull request as ready for review May 12, 2026 13:44
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Claude finished @centdix's task in 3m 45s —— View job


Re-review for PR #231 — workspace labels (post-fix pass)

The fix commit (fa33363) addresses every concrete concern from the prior review. Nice turnaround.


Previously raised — all resolved

  • LifecycleService.setWorktreeLabel side-effectsbackend/src/services/lifecycle-service.ts:358-372 now reads meta, writes the label, and reconciles. No more refreshManagedArtifacts / initializeUnmanagedWorktree. Unmanaged worktrees correctly throw LifecycleError(409) instead of silently promoting to managed. Backed by the new test rejects labeling unmanaged worktrees without creating metadata (backend/src/__tests__/lifecycle-service.test.ts:1098-1124) which asserts none of meta.json, runtime.env, or control.env is created.
  • Dialog $effect$derivedfrontend/src/lib/WorktreeLabelDialog.svelte:25-27 uses $derived for normalizedInitialLabel, normalizedLabel, and canSave. The prop→state sync is gone, so a mid-typing snapshot update no longer overwrites user input. Conforms to frontend/CLAUDE.md ("Prefer $derived over $effect for computed values").
  • --label + positional concatenationbin/src/worktree-commands.ts:332-367 separates optionLabel from labelParts and rejects both the duplicate---label case and the mixed-form case with clear messages. Covered by bin/src/worktree-commands.test.ts:269-272.
  • Save UX (no-op submits)canSave (WorktreeLabelDialog.svelte:27) is now !loading && normalizedLabel !== normalizedInitialLabel. The form's submit handler is also guarded (if (canSave) onconfirm(normalizedLabel)). Backed by does not submit unchanged labels in WorktreeLabelDialog.test.ts:73-81.
  • normalizeWorktreeMeta reference-equality short-circuit — Restored at backend/src/adapters/fs.ts:228-230 (early return when both conversation and label already normalized). Note: delete on properties of a freshly-spread object is fine here, though the equivalent destructure form would be slightly more idiomatic.
  • Dialog confirms send trimmed labelWorktreeLabelDialog.svelte:37 passes normalizedLabel to onconfirm.
  • Dedicated dialog test — New frontend/src/lib/WorktreeLabelDialog.test.ts covers disabled-Clear/Save with no label, clear, trimmed-and-changed submit, unchanged-no-submit, and cancel.

One small leftover (non-blocking)

  • MAX_WORKTREE_LABEL_LENGTH = 80 duplicationbackend/src/services/lifecycle-service.ts:45 still hardcodes 80 alongside packages/api-contract/src/schemas.ts:122's .max(80). Since the schema rejects oversized payloads at the boundary, the secondary check is double-wallpaper. Either drop the in-service check or move the constant into the api-contract package and import it. Cosmetic.

Minor doc polish

  • getWorktreeCommandUsage("label") still lists --label <text> as an option but only shows positional <label> in the Usage block (bin/src/worktree-commands.ts:111-121). Adding a third usage line webmux label <branch> --label "<text>" would make it explicit that --label is a third form (not mixable with positional), matching the new parse rules. Optional.

LGTM otherwise

  • Label propagates cleanly through meta → reconciliation → runtime → snapshot → frontend mapping → UI.
  • withUpdatedLabel (lifecycle-service.ts:634-642) cleanly clears via delete rather than storing empty strings.
  • BaseDialog uses <dialog> + showModal(), so Escape is handled natively via the dialog's close event wired to onclose — no extra keydown listener needed.
  • CLI parity (label subcommand + completions + label-aware list/search) and test coverage across surfaces (backend, fs, snapshot, CLI parser, frontend dialog, sidebar, top bar) are thorough.

Todos

  • Read repo conventions
  • Diff origin/main...HEAD and identify what changed in the fix commit
  • Re-check lifecycle-service.setWorktreeLabel side-effects concern
  • Re-check WorktreeLabelDialog.svelte ($effect vs $derived, Save UX, trim)
  • Re-check worktree-commands.parseLabelCommandArgs --label + positional handling
  • Re-check adapters/fs.normalizeWorktreeMeta short-circuit
  • Re-check MAX_WORKTREE_LABEL_LENGTH duplication vs zod schema (still duplicated, non-blocking)
  • Look for new issues introduced by the fix commit (none found)
  • Post final review

Looks good to merge once the MAX_WORKTREE_LABEL_LENGTH nit is addressed or consciously deferred.
· branch: edit-workspace-labels

@centdix
centdix merged commit 04162db into main May 12, 2026
3 checks passed
@centdix
centdix deleted the edit-workspace-labels branch May 12, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant