feat(groups): session Claude unique par groupe via --add-dir (P6)
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
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.
This commit is contained in:
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type {
|
||||
CreateGroupRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateGroupSessionRequest,
|
||||
GroupResponse,
|
||||
GroupSessionResponse,
|
||||
GroupsListResponse,
|
||||
GroupSummary,
|
||||
RepoSummary,
|
||||
@@ -14,17 +15,32 @@ import type {
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||||
import { useWorktreesStore } from './worktrees';
|
||||
import { useSessionsStore } from './sessions';
|
||||
|
||||
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */
|
||||
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
|
||||
|
||||
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
|
||||
/** 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;
|
||||
sessionId?: 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', () => {
|
||||
@@ -68,7 +84,12 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||||
}
|
||||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||||
return worktreesInGroup(groupId).flatMap((w) => w.sessions);
|
||||
// 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> {
|
||||
@@ -109,40 +130,57 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action.
|
||||
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère
|
||||
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo.
|
||||
* 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 createCrossRepoFeature(
|
||||
async function createGroupFeature(
|
||||
groupId: string,
|
||||
repoIds: string[],
|
||||
req: CrossRepoRequest,
|
||||
opts: GroupFeatureOptions,
|
||||
onProgress?: (result: CrossRepoResult) => void,
|
||||
): Promise<CrossRepoResult[]> {
|
||||
): Promise<GroupFeatureOutcome> {
|
||||
const wt = useWorktreesStore();
|
||||
return Promise.all(
|
||||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||
try {
|
||||
const res = await wt.createWorktree(repoId, req);
|
||||
const result: CrossRepoResult = {
|
||||
repoId,
|
||||
status: 'ok',
|
||||
worktreePath: res.worktree.path,
|
||||
...(res.session ? { sessionId: res.session.id } : {}),
|
||||
};
|
||||
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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
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 {
|
||||
@@ -168,7 +206,8 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
deleteGroup,
|
||||
addRepoToGroup,
|
||||
removeRepoFromGroup,
|
||||
createCrossRepoFeature,
|
||||
createGroupSession,
|
||||
createGroupFeature,
|
||||
startRealtime,
|
||||
stopRealtime,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user