Files
arboretum/packages/web/src/stores/groups.ts
Johan LEROY c6ea9f7930
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
feat(groups): session Claude unique par groupe via --add-dir (P6)
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.
2026-06-22 10:06:23 +02:00

215 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
});