Skip to content

Commit 04162db

Browse files
centdixclaude
andauthored
feat: add workspace labels (#231)
* feat: add workspace labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address workspace label review Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0f19a04 commit 04162db

37 files changed

Lines changed: 869 additions & 25 deletions

backend/src/__tests__/agents-ui-service.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ describe("buildAgentsUiWorktreeSummary", () => {
55
it("maps worktree snapshots into conversation-aware summary data", () => {
66
const summary = buildAgentsUiWorktreeSummary({
77
branch: "feature/search",
8+
label: "Search ranking",
89
baseBranch: "main",
910
path: "/repo/__worktrees/feature-search",
1011
dir: "/repo/__worktrees/feature-search",
@@ -87,6 +88,7 @@ describe("buildAgentsUiWorktreeSummary", () => {
8788
it("defaults missing conversation metadata to null", () => {
8889
const summary = buildAgentsUiWorktreeSummary({
8990
branch: "feature/idle",
91+
label: null,
9092
path: "/repo/__worktrees/feature-idle",
9193
dir: "/repo/__worktrees/feature-idle",
9294
archived: false,

backend/src/__tests__/claude-conversation-service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function makeMeta(): WorktreeMeta {
4646
function makeWorktree(): WorktreeSnapshot {
4747
return {
4848
branch: "claude-feature",
49+
label: null,
4950
path: "/tmp/worktrees/claude-feature",
5051
dir: "claude-feature",
5152
archived: false,

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,66 @@ describe("LifecycleService", () => {
10631063
expect(archiveState.entries[0]?.path).toBe(join(repoRoot, "__worktrees", "feature-archive"));
10641064
});
10651065

1066+
it("updates and clears a worktree label in metadata and runtime state", async () => {
1067+
const repoRoot = await initRepo();
1068+
const runtime = new ProjectRuntime();
1069+
const tmux = new FakeTmuxGateway();
1070+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime);
1071+
1072+
const worktreePath = join(repoRoot, "__worktrees", "feature-label");
1073+
await lifecycle.createWorktree({ branch: "feature-label" });
1074+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
1075+
const paths = getWorktreeStoragePaths(gitDir);
1076+
await Bun.write(paths.runtimeEnvPath, "runtime-marker\n");
1077+
await Bun.write(paths.controlEnvPath, "control-marker\n");
1078+
const allocatedPorts = { ...(await readWorktreeMeta(gitDir))?.allocatedPorts };
1079+
const labeled = await lifecycle.setWorktreeLabel("feature-label", " Search ranking ");
1080+
1081+
expect(labeled).toEqual({ label: "Search ranking" });
1082+
expect((await readWorktreeMeta(gitDir))?.label).toBe("Search ranking");
1083+
expect(runtime.getWorktreeByBranch("feature-label")?.label).toBe("Search ranking");
1084+
expect(await Bun.file(paths.runtimeEnvPath).text()).toBe("runtime-marker\n");
1085+
expect(await Bun.file(paths.controlEnvPath).text()).toBe("control-marker\n");
1086+
expect((await readWorktreeMeta(gitDir))?.allocatedPorts).toEqual(allocatedPorts);
1087+
1088+
const cleared = await lifecycle.setWorktreeLabel("feature-label", "");
1089+
1090+
expect(cleared).toEqual({ label: null });
1091+
expect((await readWorktreeMeta(gitDir))?.label).toBeUndefined();
1092+
expect(runtime.getWorktreeByBranch("feature-label")?.label).toBeNull();
1093+
expect(await Bun.file(paths.runtimeEnvPath).text()).toBe("runtime-marker\n");
1094+
expect(await Bun.file(paths.controlEnvPath).text()).toBe("control-marker\n");
1095+
expect((await readWorktreeMeta(gitDir))?.allocatedPorts).toEqual(allocatedPorts);
1096+
});
1097+
1098+
it("rejects labeling unmanaged worktrees without creating metadata", async () => {
1099+
const repoRoot = await initRepo();
1100+
const runtime = new ProjectRuntime();
1101+
const tmux = new FakeTmuxGateway();
1102+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime);
1103+
const git = new BunGitGateway();
1104+
const worktreePath = join(repoRoot, "__worktrees", "feature-unmanaged-label");
1105+
1106+
git.createWorktree({
1107+
repoRoot,
1108+
worktreePath,
1109+
branch: "feature-unmanaged-label",
1110+
mode: "new",
1111+
baseBranch: "main",
1112+
});
1113+
const gitDir = git.resolveWorktreeGitDir(worktreePath);
1114+
const paths = getWorktreeStoragePaths(gitDir);
1115+
1116+
await expect(lifecycle.setWorktreeLabel("feature-unmanaged-label", "Search ranking"))
1117+
.rejects.toMatchObject({
1118+
status: 409,
1119+
message: "Worktree feature-unmanaged-label has no managed metadata to label",
1120+
});
1121+
expect(await Bun.file(paths.metaPath).exists()).toBe(false);
1122+
expect(await Bun.file(paths.runtimeEnvPath).exists()).toBe(false);
1123+
expect(await Bun.file(paths.controlEnvPath).exists()).toBe(false);
1124+
});
1125+
10661126
it("creates a managed docker worktree through the container runtime path", async () => {
10671127
const repoRoot = await initRepo();
10681128
const runtime = new ProjectRuntime();

backend/src/__tests__/native-terminal-service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ function makeState(overrides: Partial<ManagedWorktreeRuntimeState> = {}): Manage
66
return {
77
worktreeId: "wt_feature_search",
88
branch: "feature/search",
9+
label: null,
910
baseBranch: null,
1011
path: "/repo/__worktrees/feature-search",
1112
profile: "default",

backend/src/__tests__/project-runtime.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("ProjectRuntime", () => {
1616

1717
expect(state.worktreeId).toBe("wt_search");
1818
expect(state.branch).toBe("feature/search");
19+
expect(state.label).toBeNull();
1920
expect(state.baseBranch).toBe("main");
2021
expect(state.profile).toBe("default");
2122
expect(state.agentName).toBe("claude");
@@ -109,4 +110,26 @@ describe("ProjectRuntime", () => {
109110
expect(runtime.getWorktreeByBranch("feature/search")).toBeNull();
110111
expect(runtime.getWorktreeByBranch("feature/search-v2")?.worktreeId).toBe("wt_search");
111112
});
113+
114+
it("updates label metadata without changing branch lookups", () => {
115+
const runtime = new ProjectRuntime();
116+
runtime.upsertWorktree({
117+
worktreeId: "wt_search",
118+
branch: "feature/search",
119+
label: "Search UI",
120+
path: "/repo/__worktrees/feature-search",
121+
runtime: "host",
122+
});
123+
124+
const state = runtime.upsertWorktree({
125+
worktreeId: "wt_search",
126+
branch: "feature/search",
127+
label: "Search ranking",
128+
path: "/repo/__worktrees/feature-search",
129+
runtime: "host",
130+
});
131+
132+
expect(state.label).toBe("Search ranking");
133+
expect(runtime.getWorktreeByBranch("feature/search")?.label).toBe("Search ranking");
134+
});
112135
});

backend/src/__tests__/snapshot-service.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("buildProjectSnapshot", () => {
99
runtime.upsertWorktree({
1010
worktreeId: "wt_search",
1111
branch: "feature/search",
12+
label: "Search ranking",
1213
baseBranch: "main",
1314
path: "/repo/__worktrees/feature-search",
1415
profile: "default",
@@ -100,6 +101,7 @@ describe("buildProjectSnapshot", () => {
100101
expect(snapshot.worktrees).toEqual([
101102
{
102103
branch: "feature/search",
104+
label: "Search ranking",
103105
baseBranch: "main",
104106
path: "/repo/__worktrees/feature-search",
105107
dir: "/repo/__worktrees/feature-search",
@@ -219,6 +221,7 @@ describe("buildProjectSnapshot", () => {
219221
expect(snapshot.worktrees).toEqual([
220222
{
221223
branch: "feature/new-flow",
224+
label: null,
222225
baseBranch: "main",
223226
path: "/repo/__worktrees/feature/new-flow",
224227
dir: "/repo/__worktrees/feature/new-flow",

backend/src/__tests__/worktree-conversation-service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function makeCodexConversationMeta(threadId: string, cwd: string, lastSeenAt = "
162162
function makeWorktree(): WorktreeSnapshot {
163163
return {
164164
branch: "codex-feature",
165+
label: null,
165166
path: "/tmp/worktrees/codex-feature",
166167
dir: "codex-feature",
167168
archived: false,

backend/src/__tests__/worktree-storage.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
readWorktreeMeta,
1414
renderEnvFile,
1515
writeWorktreePrs,
16+
writeWorktreeMeta,
1617
} from "../adapters/fs";
1718
import type { WorktreeMeta } from "../domain/model";
1819
import { createManagedWorktree, initializeManagedWorktree } from "../services/worktree-service";
@@ -398,6 +399,17 @@ describe("initializeManagedWorktree", () => {
398399
});
399400
});
400401

402+
it("normalizes blank labels out of worktree metadata", async () => {
403+
gitDir = await mkdtemp(join(tmpdir(), "webmux-meta-label-"));
404+
405+
await writeWorktreeMeta(gitDir, {
406+
...makeMeta(),
407+
label: " ",
408+
});
409+
410+
expect((await readWorktreeMeta(gitDir))?.label).toBeUndefined();
411+
});
412+
401413
it("round-trips PR storage through the worktree webmux dir", async () => {
402414
gitDir = await mkdtemp(join(tmpdir(), "webmux-prs-gitdir-"));
403415

backend/src/adapters/fs.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,25 @@ function normalizeConversationMeta(raw: WorktreeConversationMeta | null | undefi
218218
return normalized;
219219
}
220220

221+
function normalizeOptionalString(raw: unknown): string | undefined {
222+
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
223+
}
224+
221225
function normalizeWorktreeMeta(meta: WorktreeMeta): WorktreeMeta {
222226
const conversation = normalizeConversationMeta(meta.conversation);
223-
return conversation === meta.conversation
224-
? meta
225-
: {
226-
...meta,
227-
conversation,
228-
};
227+
const normalizedLabel = normalizeOptionalString(meta.label);
228+
if (conversation === meta.conversation && normalizedLabel === meta.label) {
229+
return meta;
230+
}
231+
232+
const rest: WorktreeMeta = { ...meta };
233+
delete rest.label;
234+
delete rest.conversation;
235+
return {
236+
...rest,
237+
...(normalizedLabel ? { label: normalizedLabel } : {}),
238+
...(conversation !== undefined ? { conversation } : {}),
239+
};
229240
}
230241

231242
function isPrComment(raw: unknown): raw is PrComment {

backend/src/domain/model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface WorktreeMeta {
3030
schemaVersion: number;
3131
worktreeId: string;
3232
branch: string;
33+
label?: string;
3334
baseBranch?: string;
3435
createdAt: string;
3536
profile: string;
@@ -162,6 +163,7 @@ export interface WorktreeCreationSnapshot {
162163
export interface ManagedWorktreeRuntimeState {
163164
worktreeId: string;
164165
branch: string;
166+
label: string | null;
165167
baseBranch: string | null;
166168
path: string;
167169
profile: string | null;
@@ -184,6 +186,7 @@ export interface NotificationView {
184186

185187
export interface WorktreeSnapshot {
186188
branch: string;
189+
label: string | null;
187190
baseBranch?: string;
188191
path: string;
189192
dir: string;

0 commit comments

Comments
 (0)