All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
Un groupe lance désormais UNE seule session Claude couvrant tous ses
repos/worktrees (--add-dir) au lieu d'une session par repo, pour
travailler en simultané dans une conversation partagée. Le transport WS,
le flow control et l'attach sont inchangés (toujours 1 session, 1 channel).
- shared: SessionSummary.addedDirs/groupId (champs additifs optionnels) ;
CreateGroupSessionRequest / GroupSessionResponse.
- server: --add-dir dans claude-launcher ; spawn({addDirs,groupId}) +
persistance (migration #8 : sessions.added_dirs/group_id) ;
POST /api/v1/groups/:id/session (résolution des dirs cote serveur) ;
resume re-relie les dirs ; deleteGroup nullifie group_id.
- web: GroupSessionModal (modes feature / checkouts principaux) ;
createGroupFeature/createGroupSession ; badge « groupe · N depots » ;
sessionsInGroup dedupliquee.
- site: textes EN/FR « une seule session Claude » + diagramme hub.
- tests: claude-launcher, pty-manager (groupe), group-manager ;
acceptation P5 etendue (session de groupe multi-repo). 272 tests verts.
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { ref } from 'vue';
|
||
import type {
|
||
CreateGroupRequest,
|
||
CreateGroupSessionRequest,
|
||
GroupResponse,
|
||
GroupSessionResponse,
|
||
GroupsListResponse,
|
||
GroupSummary,
|
||
RepoSummary,
|
||
SessionSummary,
|
||
UpdateGroupRequest,
|
||
WorktreeSummary,
|
||
} from '@arboretum/shared';
|
||
import { api, ApiError } from '../lib/api';
|
||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||
import { useWorktreesStore } from './worktrees';
|
||
import { useSessionsStore } from './sessions';
|
||
|
||
/** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */
|
||
export interface CrossRepoResult {
|
||
repoId: string;
|
||
status: 'ok' | 'error';
|
||
message?: string;
|
||
worktreePath?: string;
|
||
}
|
||
|
||
/** Options de lancement d'une session de groupe (P6). */
|
||
export interface GroupFeatureOptions {
|
||
command: 'claude' | 'bash';
|
||
/** présent → crée le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
||
branch?: string;
|
||
newBranch?: boolean;
|
||
baseRef?: string;
|
||
}
|
||
|
||
/** Bilan d'un lancement de session de groupe. */
|
||
export interface GroupFeatureOutcome {
|
||
/** résultats de création de worktree par repo ([] en mode checkouts principaux). */
|
||
worktreeResults: CrossRepoResult[];
|
||
session: SessionSummary | null;
|
||
dirs: string[];
|
||
skipped: Array<{ repoId: string; reason: string }>;
|
||
}
|
||
|
||
export const useGroupsStore = defineStore('groups', () => {
|
||
const groups = ref<GroupSummary[]>([]);
|
||
const selectedGroupId = ref<string | null>(null);
|
||
const loading = ref(false);
|
||
const loadError = ref<string | null>(null);
|
||
let unsubscribe: (() => void) | null = null;
|
||
|
||
function upsert(group: GroupSummary): void {
|
||
const idx = groups.value.findIndex((g) => g.id === group.id);
|
||
if (idx >= 0) groups.value.splice(idx, 1, group);
|
||
else groups.value.push(group);
|
||
}
|
||
function removeLocal(id: string): void {
|
||
groups.value = groups.value.filter((g) => g.id !== id);
|
||
if (selectedGroupId.value === id) selectedGroupId.value = null;
|
||
}
|
||
function onEvent(e: GroupEvent): void {
|
||
if (e.type === 'group_update') upsert(e.group);
|
||
else removeLocal(e.groupId);
|
||
}
|
||
|
||
function byId(id: string): GroupSummary | undefined {
|
||
return groups.value.find((g) => g.id === id);
|
||
}
|
||
|
||
// ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ----
|
||
function reposInGroup(groupId: string): RepoSummary[] {
|
||
const g = byId(groupId);
|
||
if (!g) return [];
|
||
const wt = useWorktreesStore();
|
||
return g.repoIds
|
||
.map((id) => wt.repos.find((r) => r.id === id))
|
||
.filter((r): r is RepoSummary => r !== undefined);
|
||
}
|
||
function worktreesInGroup(groupId: string): WorktreeSummary[] {
|
||
const g = byId(groupId);
|
||
if (!g) return [];
|
||
const ids = new Set(g.repoIds);
|
||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||
}
|
||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||
// Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) },
|
||
// dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois.
|
||
const byId = new Map<string, SessionSummary>();
|
||
for (const w of worktreesInGroup(groupId)) for (const s of w.sessions) byId.set(s.id, s);
|
||
for (const s of useSessionsStore().sessions) if (s.groupId === groupId) byId.set(s.id, s);
|
||
return [...byId.values()];
|
||
}
|
||
|
||
async function fetchGroups(): Promise<void> {
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const res = await api.get<GroupsListResponse>('/api/v1/groups');
|
||
groups.value = res.groups;
|
||
} catch (err) {
|
||
loadError.value = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function createGroup(req: CreateGroupRequest): Promise<GroupSummary> {
|
||
const res = await api.post<GroupResponse>('/api/v1/groups', req);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function updateGroup(id: string, patch: UpdateGroupRequest): Promise<GroupSummary> {
|
||
const res = await api.patch<GroupResponse>(`/api/v1/groups/${id}`, patch);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function deleteGroup(id: string): Promise<void> {
|
||
await api.delete<{ ok: true }>(`/api/v1/groups/${id}`);
|
||
removeLocal(id);
|
||
}
|
||
async function addRepoToGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||
const res = await api.post<GroupResponse>(`/api/v1/groups/${groupId}/repos`, { repoId });
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
async function removeRepoFromGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||
const res = await api.delete<GroupResponse>(`/api/v1/groups/${groupId}/repos/${repoId}`);
|
||
upsert(res.group);
|
||
return res.group;
|
||
}
|
||
|
||
/** Lance UNE session Claude couvrant tous les repos du groupe (--add-dir côté serveur). */
|
||
async function createGroupSession(groupId: string, req: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||
// La session est upsertée dans le store sessions par le broadcast WS `session_update`.
|
||
return api.post<GroupSessionResponse>(`/api/v1/groups/${groupId}/session`, req);
|
||
}
|
||
|
||
/**
|
||
* Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie),
|
||
* crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante
|
||
* aux échecs partiels, feedback par repo — PUIS une seule session couvrant ces worktrees. En mode
|
||
* « checkouts principaux » (sans branch), la session couvre directement le worktree principal de chaque repo.
|
||
*/
|
||
async function createGroupFeature(
|
||
groupId: string,
|
||
repoIds: string[],
|
||
opts: GroupFeatureOptions,
|
||
onProgress?: (result: CrossRepoResult) => void,
|
||
): Promise<GroupFeatureOutcome> {
|
||
const wt = useWorktreesStore();
|
||
let worktreeResults: CrossRepoResult[] = [];
|
||
if (opts.branch) {
|
||
const branch = opts.branch;
|
||
worktreeResults = await Promise.all(
|
||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||
try {
|
||
const res = await wt.createWorktree(repoId, {
|
||
branch,
|
||
newBranch: opts.newBranch ?? true,
|
||
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
||
startSession: null,
|
||
});
|
||
const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path };
|
||
onProgress?.(result);
|
||
return result;
|
||
} catch (err) {
|
||
const result: CrossRepoResult = {
|
||
repoId,
|
||
status: 'error',
|
||
message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err),
|
||
};
|
||
onProgress?.(result);
|
||
return result;
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
const res = await createGroupSession(groupId, {
|
||
command: opts.command,
|
||
...(opts.branch ? { branch: opts.branch } : {}),
|
||
});
|
||
return { worktreeResults, session: res.session, dirs: res.dirs, skipped: res.skipped };
|
||
}
|
||
|
||
function startRealtime(): void {
|
||
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||
}
|
||
function stopRealtime(): void {
|
||
unsubscribe?.();
|
||
unsubscribe = null;
|
||
}
|
||
|
||
return {
|
||
groups,
|
||
selectedGroupId,
|
||
loading,
|
||
loadError,
|
||
byId,
|
||
reposInGroup,
|
||
worktreesInGroup,
|
||
sessionsInGroup,
|
||
fetchGroups,
|
||
createGroup,
|
||
updateGroup,
|
||
deleteGroup,
|
||
addRepoToGroup,
|
||
removeRepoFromGroup,
|
||
createGroupSession,
|
||
createGroupFeature,
|
||
startRealtime,
|
||
stopRealtime,
|
||
};
|
||
});
|