Skip to content

Commit 2e7eb8b

Browse files
committed
Support selectable workspace configs
1 parent 9b17537 commit 2e7eb8b

37 files changed

Lines changed: 923 additions & 179 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ ENV/
4545

4646
# CC Branch local runtime state
4747
.cc-branch/state.yaml
48+
.cc-branch/states/
4849
.cc-branch/*.bak
4950
.cc-branch.state.yaml
5051
.cc-branch.state.yaml.bak

apps/web/src/App.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { I18nProvider, useI18n } from "./i18n";
1313
import { ThemeProvider, useTheme } from "./theme/ThemeProvider";
1414
import { ToastProvider } from "./components/ui/Toast";
15-
import { useApiClient, useKeyboardShortcuts } from "./hooks";
15+
import { useApiClient, useConfigOptions, useKeyboardShortcuts } from "./hooks";
1616
import { useProjectStore, getActiveProject } from "./stores/projectStore";
1717
import { useUIStore } from "./stores/uiStore";
1818
import Sidebar from "./components/Sidebar";
@@ -26,6 +26,7 @@ import Dashboard from "./components/Dashboard";
2626
import ConfigEditor from "./components/ConfigEditor";
2727
import DoctorView from "./components/DoctorView";
2828
import SettingsModal from "./components/SettingsModal";
29+
import ConfigSelector from "./components/ConfigSelector";
2930
import { projectDirFromConfigPath } from "./utils/projectPath";
3031

3132
type Tab = "dashboard" | "config" | "doctor";
@@ -57,6 +58,11 @@ function AppInner() {
5758
const setMobileSidebarOpen = useUIStore((s) => s.setMobileSidebarOpen);
5859
const [addModalOpen, setAddModalOpen] = useState(false);
5960
const [settingsOpen, setSettingsOpen] = useState(false);
61+
const [selectedConfigPaths, setSelectedConfigPaths] = useState<Record<string, string>>({});
62+
const activeConfigPath = activeProject?.path ? selectedConfigPaths[activeProject.path] : undefined;
63+
const activeScope = activeProject ? { projectPath: activeProject.path, configPath: activeConfigPath } : undefined;
64+
const { data: configOptionsData } = useConfigOptions(activeScope);
65+
const selectedConfigPath = configOptionsData?.selected_config_path || activeConfigPath;
6066

6167
// Auto-inject current project on mount
6268
useEffect(() => {
@@ -90,6 +96,11 @@ function AppInner() {
9096
);
9197

9298
const handleSetTab = useCallback((id: Tab) => setTab(id), []);
99+
const handleSelectConfig = useCallback((path: string) => {
100+
if (!activeProject?.path) return;
101+
setSelectedConfigPaths((prev) => ({ ...prev, [activeProject.path]: path }));
102+
setTab("dashboard");
103+
}, [activeProject?.path]);
93104

94105
const handleOpenSettings = useCallback(() => {
95106
setMobileSidebarOpen(false);
@@ -160,6 +171,13 @@ function AppInner() {
160171
</div>
161172

162173
<div className="flex items-center gap-0.5 shrink-0">
174+
{configOptionsData?.configs && activeProject && (
175+
<ConfigSelector
176+
configs={configOptionsData.configs}
177+
selectedPath={selectedConfigPath}
178+
onSelect={handleSelectConfig}
179+
/>
180+
)}
163181
<Dropdown
164182
align="right"
165183
value={lang}
@@ -254,13 +272,13 @@ function AppInner() {
254272
{activeProject ? (
255273
<>
256274
<div role="tabpanel" id="panel-dashboard" aria-labelledby="tab-dashboard" hidden={tab !== "dashboard"} className={`transition-opacity duration-200 ${tab === "dashboard" ? "opacity-100" : "opacity-0 hidden"}`}>
257-
<Dashboard key={`dash-${activeProject.id}`} projectPath={activeProject.path} isActive={tab === "dashboard"} />
275+
<Dashboard key={`dash-${activeProject.id}-${selectedConfigPath || "default"}`} projectPath={activeProject.path} configPath={selectedConfigPath} isActive={tab === "dashboard"} />
258276
</div>
259277
<div role="tabpanel" id="panel-config" aria-labelledby="tab-config" hidden={tab !== "config"} className={`transition-opacity duration-200 ${tab === "config" ? "opacity-100" : "opacity-0 hidden"}`}>
260-
<ConfigEditor key={`cfg-${activeProject.id}`} projectPath={activeProject.path} />
278+
<ConfigEditor key={`cfg-${activeProject.id}-${selectedConfigPath || "default"}`} projectPath={activeProject.path} configPath={selectedConfigPath} />
261279
</div>
262280
<div role="tabpanel" id="panel-doctor" aria-labelledby="tab-doctor" hidden={tab !== "doctor"} className={`transition-opacity duration-200 ${tab === "doctor" ? "opacity-100" : "opacity-0 hidden"}`}>
263-
<DoctorView key={`doc-${activeProject.id}`} projectPath={activeProject.path} />
281+
<DoctorView key={`doc-${activeProject.id}-${selectedConfigPath || "default"}`} projectPath={activeProject.path} configPath={selectedConfigPath} />
264282
</div>
265283
</>
266284
) : (

apps/web/src/api/client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { HTTPClient } from "./client";
3+
4+
describe("HTTPClient workspace scope", () => {
5+
afterEach(() => {
6+
vi.restoreAllMocks();
7+
});
8+
9+
it("sends project and config path query parameters together", async () => {
10+
const fetchMock = vi.fn().mockResolvedValue({
11+
ok: true,
12+
json: async () => ({ config_path: "/tmp/demo/.cc-branch/configs/review.yaml", state_path: "/tmp/demo/.cc-branch/states/review.yaml", slots: [] }),
13+
});
14+
vi.stubGlobal("fetch", fetchMock);
15+
16+
await new HTTPClient().getStatus({
17+
projectPath: "/tmp/demo",
18+
configPath: "/tmp/demo/.cc-branch/configs/review.yaml",
19+
});
20+
21+
expect(fetchMock).toHaveBeenCalledWith(
22+
"/api/status?project_path=%2Ftmp%2Fdemo&config_path=%2Ftmp%2Fdemo%2F.cc-branch%2Fconfigs%2Freview.yaml",
23+
{ signal: undefined }
24+
);
25+
});
26+
});

apps/web/src/api/client.ts

Lines changed: 80 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,27 @@ import type {
2020
AgentsData,
2121
ConfigSaveResult,
2222
ConfigIssue,
23+
ConfigOptionsData,
24+
WorkspaceScope,
2325
} from "../types";
2426

2527
export interface APIClient {
26-
getStatus(projectPath?: string, signal?: AbortSignal): Promise<WorkspaceStatus>;
27-
getConfig(projectPath?: string, signal?: AbortSignal): Promise<ConfigData>;
28-
getDoctor(projectPath?: string, signal?: AbortSignal): Promise<DoctorReport>;
28+
getStatus(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<WorkspaceStatus>;
29+
getConfig(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<ConfigData>;
30+
getConfigs(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<ConfigOptionsData>;
31+
getDoctor(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<DoctorReport>;
2932
probeProject(projectPath: string, signal?: AbortSignal): Promise<ProjectProbe>;
30-
getOpeners(projectPath?: string, signal?: AbortSignal): Promise<OpenersData>;
31-
getAgents(projectPath?: string, signal?: AbortSignal): Promise<AgentsData>;
32-
runAction(action: WorkspaceAction, target?: string, projectPath?: string): Promise<ActionResult>;
33+
getOpeners(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<OpenersData>;
34+
getAgents(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<AgentsData>;
35+
runAction(action: WorkspaceAction, target?: string, scope?: WorkspaceScope | string): Promise<ActionResult>;
3336
runWorkspaceAction(request: WorkspaceActionRequest): Promise<ActionResult>;
34-
stopSlot(sessionName: string, projectPath?: string): Promise<ActionResult>;
37+
stopSlot(sessionName: string, scope?: WorkspaceScope | string): Promise<ActionResult>;
3538
getApiInfo(signal?: AbortSignal): Promise<{ port: number; config_path: string; state_path: string }>;
3639
getProfiles(signal?: AbortSignal): Promise<Profile[]>;
37-
initWorkspace(profile: string, bootstrapSessions: boolean, projectPath?: string): Promise<InitResult>;
40+
initWorkspace(profile: string, bootstrapSessions: boolean, scope?: WorkspaceScope | string): Promise<InitResult>;
3841
saveConfig(
3942
content: string,
40-
projectPath?: string,
43+
scope?: WorkspaceScope | string,
4144
baseMtime?: number | null,
4245
baseContentHash?: string | null
4346
): Promise<ConfigSaveResult>;
@@ -59,8 +62,19 @@ export class APIRequestError extends Error {
5962
}
6063
}
6164

62-
function qs(projectPath?: string): string {
63-
return projectPath ? `?project_path=${encodeURIComponent(projectPath)}` : "";
65+
function normalizeScope(scope?: WorkspaceScope | string): WorkspaceScope {
66+
if (!scope) return {};
67+
if (typeof scope === "string") return { projectPath: scope };
68+
return scope;
69+
}
70+
71+
function qs(scope?: WorkspaceScope | string): string {
72+
const { projectPath, configPath } = normalizeScope(scope);
73+
const params = new URLSearchParams();
74+
if (projectPath) params.set("project_path", projectPath);
75+
if (configPath) params.set("config_path", configPath);
76+
const query = params.toString();
77+
return query ? `?${query}` : "";
6478
}
6579

6680
/**
@@ -73,22 +87,29 @@ export class HTTPClient implements APIClient {
7387
this.baseUrl = baseUrl.replace(/\/$/, "");
7488
}
7589

76-
async getStatus(projectPath?: string, signal?: AbortSignal): Promise<WorkspaceStatus> {
77-
const res = await fetch(`${this.baseUrl}/api/status${qs(projectPath)}`, { signal });
90+
async getStatus(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<WorkspaceStatus> {
91+
const res = await fetch(`${this.baseUrl}/api/status${qs(scope)}`, { signal });
7892
const data = await res.json();
7993
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
8094
return data as WorkspaceStatus;
8195
}
8296

83-
async getConfig(projectPath?: string, signal?: AbortSignal): Promise<ConfigData> {
84-
const res = await fetch(`${this.baseUrl}/api/config${qs(projectPath)}`, { signal });
97+
async getConfig(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<ConfigData> {
98+
const res = await fetch(`${this.baseUrl}/api/config${qs(scope)}`, { signal });
8599
const data = await res.json();
86100
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
87101
return data as ConfigData;
88102
}
89103

90-
async getDoctor(projectPath?: string, signal?: AbortSignal): Promise<DoctorReport> {
91-
const res = await fetch(`${this.baseUrl}/api/doctor${qs(projectPath)}`, { signal });
104+
async getConfigs(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<ConfigOptionsData> {
105+
const res = await fetch(`${this.baseUrl}/api/configs${qs(scope)}`, { signal });
106+
const data = await res.json();
107+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
108+
return data as ConfigOptionsData;
109+
}
110+
111+
async getDoctor(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<DoctorReport> {
112+
const res = await fetch(`${this.baseUrl}/api/doctor${qs(scope)}`, { signal });
92113
const data = await res.json();
93114
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
94115
return data as DoctorReport;
@@ -101,27 +122,27 @@ export class HTTPClient implements APIClient {
101122
return data as ProjectProbe;
102123
}
103124

104-
async getOpeners(projectPath?: string, signal?: AbortSignal): Promise<OpenersData> {
105-
const res = await fetch(`${this.baseUrl}/api/openers${qs(projectPath)}`, { signal });
125+
async getOpeners(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<OpenersData> {
126+
const res = await fetch(`${this.baseUrl}/api/openers${qs(scope)}`, { signal });
106127
const data = await res.json();
107128
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
108129
return data as OpenersData;
109130
}
110131

111-
async getAgents(projectPath?: string, signal?: AbortSignal): Promise<AgentsData> {
112-
const res = await fetch(`${this.baseUrl}/api/agents${qs(projectPath)}`, { signal });
132+
async getAgents(scope?: WorkspaceScope | string, signal?: AbortSignal): Promise<AgentsData> {
133+
const res = await fetch(`${this.baseUrl}/api/agents${qs(scope)}`, { signal });
113134
const data = await res.json();
114135
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
115136
return data as AgentsData;
116137
}
117138

118-
async runAction(action: WorkspaceAction, target?: string, projectPath?: string): Promise<ActionResult> {
119-
return this.runWorkspaceAction({ action, target, projectPath });
139+
async runAction(action: WorkspaceAction, target?: string, scope?: WorkspaceScope | string): Promise<ActionResult> {
140+
return this.runWorkspaceAction({ action, target, ...normalizeScope(scope) });
120141
}
121142

122143
async runWorkspaceAction(request: WorkspaceActionRequest): Promise<ActionResult> {
123-
const { projectPath, stopRemoved, ...body } = request;
124-
const res = await fetch(`${this.baseUrl}/api/action${qs(projectPath)}`, {
144+
const { projectPath, configPath, stopRemoved, ...body } = request;
145+
const res = await fetch(`${this.baseUrl}/api/action${qs({ projectPath, configPath })}`, {
125146
method: "POST",
126147
headers: { "Content-Type": "application/json" },
127148
body: JSON.stringify({ ...body, stop_removed: stopRemoved }),
@@ -131,8 +152,8 @@ export class HTTPClient implements APIClient {
131152
return data as ActionResult;
132153
}
133154

134-
async stopSlot(sessionName: string, projectPath?: string): Promise<ActionResult> {
135-
return this.runAction("stop", sessionName, projectPath);
155+
async stopSlot(sessionName: string, scope?: WorkspaceScope | string): Promise<ActionResult> {
156+
return this.runAction("stop", sessionName, scope);
136157
}
137158

138159
async getApiInfo(signal?: AbortSignal): Promise<{ port: number; config_path: string; state_path: string }> {
@@ -149,8 +170,8 @@ export class HTTPClient implements APIClient {
149170
return data.profiles as Profile[];
150171
}
151172

152-
async initWorkspace(profile: string, bootstrapSessions: boolean, projectPath?: string): Promise<InitResult> {
153-
const res = await fetch(`${this.baseUrl}/api/init${qs(projectPath)}`, {
173+
async initWorkspace(profile: string, bootstrapSessions: boolean, scope?: WorkspaceScope | string): Promise<InitResult> {
174+
const res = await fetch(`${this.baseUrl}/api/init${qs(scope)}`, {
154175
method: "POST",
155176
headers: { "Content-Type": "application/json" },
156177
body: JSON.stringify({ profile, bootstrap_sessions: bootstrapSessions }),
@@ -162,11 +183,11 @@ export class HTTPClient implements APIClient {
162183

163184
async saveConfig(
164185
content: string,
165-
projectPath?: string,
186+
scope?: WorkspaceScope | string,
166187
baseMtime?: number | null,
167188
baseContentHash?: string | null
168189
): Promise<ConfigSaveResult> {
169-
const res = await fetch(`${this.baseUrl}/api/config${qs(projectPath)}`, {
190+
const res = await fetch(`${this.baseUrl}/api/config${qs(scope)}`, {
170191
method: "POST",
171192
headers: { "Content-Type": "application/json" },
172193
body: JSON.stringify({
@@ -195,25 +216,33 @@ export class TauriClient implements APIClient {
195216
return `http://127.0.0.1:${info.port}`;
196217
}
197218

198-
async getStatus(projectPath?: string): Promise<WorkspaceStatus> {
219+
async getStatus(scope?: WorkspaceScope | string): Promise<WorkspaceStatus> {
199220
const baseUrl = await this._baseUrl();
200-
const res = await fetch(`${baseUrl}/api/status${qs(projectPath)}`);
221+
const res = await fetch(`${baseUrl}/api/status${qs(scope)}`);
201222
const data = await res.json();
202223
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
203224
return data as WorkspaceStatus;
204225
}
205226

206-
async getConfig(projectPath?: string): Promise<ConfigData> {
227+
async getConfig(scope?: WorkspaceScope | string): Promise<ConfigData> {
207228
const baseUrl = await this._baseUrl();
208-
const res = await fetch(`${baseUrl}/api/config${qs(projectPath)}`);
229+
const res = await fetch(`${baseUrl}/api/config${qs(scope)}`);
209230
const data = await res.json();
210231
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
211232
return data as ConfigData;
212233
}
213234

214-
async getDoctor(projectPath?: string): Promise<DoctorReport> {
235+
async getConfigs(scope?: WorkspaceScope | string): Promise<ConfigOptionsData> {
236+
const baseUrl = await this._baseUrl();
237+
const res = await fetch(`${baseUrl}/api/configs${qs(scope)}`);
238+
const data = await res.json();
239+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
240+
return data as ConfigOptionsData;
241+
}
242+
243+
async getDoctor(scope?: WorkspaceScope | string): Promise<DoctorReport> {
215244
const baseUrl = await this._baseUrl();
216-
const res = await fetch(`${baseUrl}/api/doctor${qs(projectPath)}`);
245+
const res = await fetch(`${baseUrl}/api/doctor${qs(scope)}`);
217246
const data = await res.json();
218247
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
219248
return data as DoctorReport;
@@ -227,30 +256,30 @@ export class TauriClient implements APIClient {
227256
return data as ProjectProbe;
228257
}
229258

230-
async getOpeners(projectPath?: string): Promise<OpenersData> {
259+
async getOpeners(scope?: WorkspaceScope | string): Promise<OpenersData> {
231260
const baseUrl = await this._baseUrl();
232-
const res = await fetch(`${baseUrl}/api/openers${qs(projectPath)}`);
261+
const res = await fetch(`${baseUrl}/api/openers${qs(scope)}`);
233262
const data = await res.json();
234263
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
235264
return data as OpenersData;
236265
}
237266

238-
async getAgents(projectPath?: string): Promise<AgentsData> {
267+
async getAgents(scope?: WorkspaceScope | string): Promise<AgentsData> {
239268
const baseUrl = await this._baseUrl();
240-
const res = await fetch(`${baseUrl}/api/agents${qs(projectPath)}`);
269+
const res = await fetch(`${baseUrl}/api/agents${qs(scope)}`);
241270
const data = await res.json();
242271
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
243272
return data as AgentsData;
244273
}
245274

246-
async runAction(action: WorkspaceAction, target?: string, projectPath?: string): Promise<ActionResult> {
247-
return this.runWorkspaceAction({ action, target, projectPath });
275+
async runAction(action: WorkspaceAction, target?: string, scope?: WorkspaceScope | string): Promise<ActionResult> {
276+
return this.runWorkspaceAction({ action, target, ...normalizeScope(scope) });
248277
}
249278

250279
async runWorkspaceAction(request: WorkspaceActionRequest): Promise<ActionResult> {
251-
const { projectPath, stopRemoved, ...body } = request;
280+
const { projectPath, configPath, stopRemoved, ...body } = request;
252281
const baseUrl = await this._baseUrl();
253-
const res = await fetch(`${baseUrl}/api/action${qs(projectPath)}`, {
282+
const res = await fetch(`${baseUrl}/api/action${qs({ projectPath, configPath })}`, {
254283
method: "POST",
255284
headers: { "Content-Type": "application/json" },
256285
body: JSON.stringify({ ...body, stop_removed: stopRemoved }),
@@ -260,8 +289,8 @@ export class TauriClient implements APIClient {
260289
return data as ActionResult;
261290
}
262291

263-
async stopSlot(sessionName: string, projectPath?: string): Promise<ActionResult> {
264-
return this.runAction("stop", sessionName, projectPath);
292+
async stopSlot(sessionName: string, scope?: WorkspaceScope | string): Promise<ActionResult> {
293+
return this.runAction("stop", sessionName, scope);
265294
}
266295

267296
async getApiInfo(): Promise<{ port: number; config_path: string; state_path: string }> {
@@ -276,9 +305,9 @@ export class TauriClient implements APIClient {
276305
return data.profiles as Profile[];
277306
}
278307

279-
async initWorkspace(profile: string, bootstrapSessions: boolean, projectPath?: string): Promise<InitResult> {
308+
async initWorkspace(profile: string, bootstrapSessions: boolean, scope?: WorkspaceScope | string): Promise<InitResult> {
280309
const baseUrl = await this._baseUrl();
281-
const res = await fetch(`${baseUrl}/api/init${qs(projectPath)}`, {
310+
const res = await fetch(`${baseUrl}/api/init${qs(scope)}`, {
282311
method: "POST",
283312
headers: { "Content-Type": "application/json" },
284313
body: JSON.stringify({ profile, bootstrap_sessions: bootstrapSessions }),
@@ -290,12 +319,12 @@ export class TauriClient implements APIClient {
290319

291320
async saveConfig(
292321
content: string,
293-
projectPath?: string,
322+
scope?: WorkspaceScope | string,
294323
baseMtime?: number | null,
295324
baseContentHash?: string | null
296325
): Promise<ConfigSaveResult> {
297326
const baseUrl = await this._baseUrl();
298-
const res = await fetch(`${baseUrl}/api/config${qs(projectPath)}`, {
327+
const res = await fetch(`${baseUrl}/api/config${qs(scope)}`, {
299328
method: "POST",
300329
headers: { "Content-Type": "application/json" },
301330
body: JSON.stringify({

0 commit comments

Comments
 (0)