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

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:
2026-06-22 10:06:23 +02:00
parent ca6700d6ce
commit c6ea9f7930
23 changed files with 619 additions and 226 deletions

View File

@@ -24,15 +24,20 @@ const check = (name, ok, detail = '') => {
}; };
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-')); const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
function initRepo(path) {
execFileSync('mkdir', ['-p', path]);
const git = (...args) => execFileSync('git', args, { cwd: path, stdio: 'pipe' });
git('init', '-b', 'main');
git('config', 'user.email', 'test@arboretum.dev');
git('config', 'user.name', 'Test');
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: path });
git('add', '-A');
git('commit', '-m', 'init');
}
const repo = join(tmp, 'demo-repo'); const repo = join(tmp, 'demo-repo');
execFileSync('mkdir', ['-p', repo]); const repo2 = join(tmp, 'demo-repo-2');
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); initRepo(repo);
git('init', '-b', 'main'); initRepo(repo2);
git('config', 'user.email', 'test@arboretum.dev');
git('config', 'user.name', 'Test');
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
git('add', '-A');
git('commit', '-m', 'init');
const srv = spawn( const srv = spawn(
'node', 'node',
@@ -109,6 +114,41 @@ try {
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id)); const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
check('broadcast WS group_update (ajout repo)', !!pushedAdd); check('broadcast WS group_update (ajout repo)', !!pushedAdd);
// ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ----
// Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota).
const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 });
const repo2Summary = (await addRepo2.json()).repo;
check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true);
await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id });
// Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo.
const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' });
const gsBody = await gsRes.json();
const gsession = gsBody.session;
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
check('session de groupe : groupId posé', gsession?.groupId === group.id);
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
// La session apparaît dans la liste globale avec son groupId.
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
const listed = sessList.sessions?.find((s) => s.id === gsession.id);
check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id);
// broadcast WS session_update reçu pour la session de groupe.
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
check('broadcast WS session_update (session de groupe)', !!pushedSession);
// groupe sans worktree résolu (branche inexistante) → 400.
const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' });
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
// Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership).
const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie);
check('DELETE session de groupe → 200', killSession.status === 200);
await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie);
// Renommage via PATCH. // Renommage via PATCH.
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' }); const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed'); check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');

View File

@@ -170,7 +170,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuthRoutes(app, auth, limiter, serverVersion, db); registerAuthRoutes(app, auth, limiter, serverVersion, db);
registerSessionRoutes(app, manager, discovery); registerSessionRoutes(app, manager, discovery);
registerRepoRoutes(app, worktrees, db); registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups, db); registerGroupRoutes(app, groups, db, worktrees, manager);
registerWorktreeRoutes(app, worktrees); registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push, db); registerPushRoutes(app, push, db);
registerSettingsRoutes(app, db, config, serverVersion, push); registerSettingsRoutes(app, db, config, serverVersion, push);

View File

@@ -10,6 +10,8 @@ export interface SpawnOptions {
command: 'claude' | 'bash'; command: 'claude' | 'bash';
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */ /** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
resume?: { claudeSessionId: string; fork?: boolean }; resume?: { claudeSessionId: string; fork?: boolean };
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
addDirs?: string[];
} }
let cachedClaudeBin: string | null = null; let cachedClaudeBin: string | null = null;
@@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
args.push('--resume', opts.resume.claudeSessionId); args.push('--resume', opts.resume.claudeSessionId);
if (opts.resume.fork) args.push('--fork-session'); if (opts.resume.fork) args.push('--fork-session');
} }
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
return { file: resolveClaudeBin(), args, env }; return { file: resolveClaudeBin(), args, env };
} }

View File

@@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter<GroupManagerEvents> {
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos. // CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id); const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
if (res.changes === 0) return false; if (res.changes === 0) return false;
// Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement.
this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id);
this.emit('group_removed', id); this.emit('group_removed', id);
return true; return true;
} }

View File

@@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500;
const CLAUDE_ID_POLL_MS = 400; const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000; const CLAUDE_ID_TIMEOUT_MS = 60_000;
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
function parseAddedDirs(raw: string | null): string[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */ /** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
export interface ClientBinding { export interface ClientBinding {
channel: number; channel: number;
@@ -48,6 +59,10 @@ interface ManagedSession {
killTimer: NodeJS.Timeout | null; killTimer: NodeJS.Timeout | null;
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */ /** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
claudeSessionId: string | null; claudeSessionId: string | null;
/** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */
addedDirs: string[];
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
groupId: string | null;
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */ /** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
tracker: SessionActivityTracker | null; tracker: SessionActivityTracker | null;
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */ /** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
@@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
super(); super();
} }
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary { spawn(opts: {
cwd: string;
command?: 'claude' | 'bash';
resume?: { claudeSessionId: string; fork?: boolean };
/** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */
addDirs?: string[];
/** groupe propriétaire (session de groupe, P6). */
groupId?: string;
}): SessionSummary {
const cwd = opts.cwd; const cwd = opts.cwd;
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) { if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 }); throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
} }
// Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd).
const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd);
for (const dir of addedDirs) {
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
}
}
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1). // Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude'); const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) }); const spec = buildSpawnSpec({
command,
...(opts.resume ? { resume: opts.resume } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
});
const id = randomUUID(); const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, { const proc = pty.spawn(spec.file, spec.args, {
name: 'xterm-256color', name: 'xterm-256color',
@@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
exited: null, exited: null,
killTimer: null, killTimer: null,
claudeSessionId: null, claudeSessionId: null,
addedDirs,
groupId: opts.groupId ?? null,
tracker: null, tracker: null,
prevActivity: null, prevActivity: null,
notifyTimer: null, notifyTimer: null,
@@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
} }
this.live.set(id, session); this.live.set(id, session);
this.db this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)') .prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null); .run(
id,
cwd,
command,
session.createdAt,
opts.resume?.claudeSessionId ?? null,
addedDirs.length ? JSON.stringify(addedDirs) : null,
session.groupId,
);
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8'))); proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null)); proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
@@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
setTimeout(tick, CLAUDE_ID_POLL_MS).unref(); setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
} }
/**
* Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id`
* persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires.
*/
groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null {
const row = this.db
.prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1')
.get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined;
if (!row) return null;
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
}
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */ /** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null { findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
for (const s of this.live.values()) { for (const s of this.live.values()) {
@@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s)); const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys()); const liveIds = new Set(this.live.keys());
const rows = this.db const rows = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100') .prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>; .all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
const historical: SessionSummary[] = rows const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id)) .filter((r) => !liveIds.has(r.id))
.map((r) => ({ .map((r) => {
id: r.id, const addedDirs = parseAddedDirs(r.added_dirs);
cwd: r.cwd, return {
command: r.command, id: r.id,
title: r.title, cwd: r.cwd,
status: 'exited', command: r.command,
live: false, title: r.title,
createdAt: r.created_at, status: 'exited' as const,
endedAt: r.ended_at, live: false,
exitCode: r.exit_code, createdAt: r.created_at,
clients: 0, endedAt: r.ended_at,
source: 'managed', exitCode: r.exit_code,
claudeSessionId: r.claude_session_id, clients: 0,
pid: null, source: 'managed' as const,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct). claudeSessionId: r.claude_session_id,
resumable: r.command === 'claude' && r.claude_session_id != null, pid: null,
attachable: false, // une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
registryStatus: null, resumable: r.command === 'claude' && r.claude_session_id != null,
})); attachable: false,
registryStatus: null,
...(addedDirs.length ? { addedDirs } : {}),
groupId: r.group_id,
};
});
return [...liveSummaries, ...historical]; return [...liveSummaries, ...historical];
} }
@@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
activity: act?.activity ?? null, activity: act?.activity ?? null,
waitingFor: act?.waitingFor ?? null, waitingFor: act?.waitingFor ?? null,
dialog: act?.dialog ?? null, dialog: act?.dialog ?? null,
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
groupId: s.groupId,
}; };
} }
} }

View File

@@ -118,6 +118,17 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
CREATE INDEX idx_audit_ts ON audit_logs(ts); CREATE INDEX idx_audit_ts ON audit_logs(ts);
`, `,
}, },
{
// P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
id: 8,
sql: `
ALTER TABLE sessions ADD COLUMN added_dirs TEXT;
ALTER TABLE sessions ADD COLUMN group_id TEXT;
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
`,
},
]; ];
export type Db = DatabaseSync; export type Db = DatabaseSync;

View File

@@ -2,16 +2,26 @@ import type { FastifyInstance } from 'fastify';
import type { import type {
AddRepoRequest, AddRepoRequest,
CreateGroupRequest, CreateGroupRequest,
CreateGroupSessionRequest,
GroupResponse, GroupResponse,
GroupSessionResponse,
GroupsListResponse, GroupsListResponse,
UpdateGroupRequest, UpdateGroupRequest,
} from '@arboretum/shared'; } from '@arboretum/shared';
import type { GroupManager } from '../core/group-manager.js'; import type { GroupManager } from '../core/group-manager.js';
import type { WorktreeManager } from '../core/worktree-manager.js';
import type { PtyManager } from '../core/pty-manager.js';
import type { Db } from '../db/index.js'; import type { Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js'; import { recordAudit } from '../core/audit-log.js';
import { sendManagerError } from './repos.js'; import { sendManagerError } from './repos.js';
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void { export function registerGroupRoutes(
app: FastifyInstance,
gm: GroupManager,
db: Db,
wt: WorktreeManager,
manager: PtyManager,
): void {
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() })); app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
app.get('/api/v1/groups/:id', async (req, reply) => { app.get('/api/v1/groups/:id', async (req, reply) => {
@@ -88,4 +98,62 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
return sendManagerError(reply, err); return sendManagerError(reply, err);
} }
}); });
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe — le client
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
// absent → le worktree principal de chaque repo.
app.post('/api/v1/groups/:id/session', async (req, reply) => {
const { id } = req.params as { id: string };
const body = (req.body as Partial<CreateGroupSessionRequest> | null) ?? {};
const command = body.command ?? 'claude';
if (command !== 'claude' && command !== 'bash') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
}
const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null;
let group;
try {
group = gm.getGroup(id);
} catch (err) {
return sendManagerError(reply, err);
}
const dirs: string[] = [];
const skipped: Array<{ repoId: string; reason: string }> = [];
for (const repoId of group.repoIds) {
let worktrees;
try {
worktrees = await wt.listRepoWorktrees(repoId);
} catch (err) {
skipped.push({ repoId, reason: err instanceof Error ? err.message : String(err) });
continue;
}
const match = branch ? worktrees.find((w) => w.branch === branch) : worktrees.find((w) => w.isMain);
if (!match) {
skipped.push({ repoId, reason: branch ? `no worktree on branch ${branch}` : 'no main worktree' });
continue;
}
if (!dirs.includes(match.path)) dirs.push(match.path);
}
const [primary, ...rest] = dirs;
if (primary === undefined) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
}
try {
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown',
action: 'group.session.create',
resourceId: id,
details: { command, dirs: dirs.length, ...(branch ? { branch } : {}) },
});
return reply.status(201).send({ session, dirs, skipped } satisfies GroupSessionResponse);
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
}
});
} }

View File

@@ -39,7 +39,14 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } }); return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
} }
try { try {
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } }); // Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
const ctx = manager.groupSessionContext(id);
const session = manager.spawn({
cwd: discovered.cwd,
resume: { claudeSessionId: id },
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
});
const res: SessionResponse = { session }; const res: SessionResponse = { session };
return reply.status(201).send(res); return reply.status(201).send(res);
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
expect(spec.file).toBe('/usr/bin/claude');
expect(spec.args).toEqual(['--add-dir', '/a', '--add-dir', '/b', '--add-dir', '/c']);
});
it('combine --resume et --add-dir (reprise dune session de groupe)', () => {
const spec = buildSpawnSpec({ command: 'claude', resume: { claudeSessionId: 'sid' }, addDirs: ['/x'] });
expect(spec.args).toEqual(['--resume', 'sid', '--add-dir', '/x']);
});
it('aucun --add-dir quand addDirs est vide/absent', () => {
expect(buildSpawnSpec({ command: 'claude' }).args).toEqual([]);
expect(buildSpawnSpec({ command: 'claude', addDirs: [] }).args).toEqual([]);
});
it('ignore addDirs pour bash (pas de --add-dir)', () => {
const spec = buildSpawnSpec({ command: 'bash', addDirs: ['/a', '/b'] });
expect(spec.file).toBe('bash');
expect(spec.args).toEqual(['--norc']);
});
});

View File

@@ -97,4 +97,15 @@ describe('GroupManager', () => {
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a'); db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']); expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
}); });
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
const g = gm.createGroup({ label: 'A' });
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
db.prepare(
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
expect(gm.deleteGroup(g.id)).toBe(true);
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
expect(row.group_id).toBeNull();
});
}); });

View File

@@ -574,4 +574,53 @@ describe('PtyManager (pty mocké)', () => {
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' }); expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
}); });
}); });
describe('session de groupe multi-repo (P6)', () => {
it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => {
const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-'));
const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-'));
try {
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' });
expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]);
expect(summary.addedDirs).toEqual([d1, d2]);
expect(summary.groupId).toBe('grp1');
const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as {
added_dirs: string | null;
group_id: string | null;
};
expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]);
expect(row.group_id).toBe('grp1');
} finally {
rmSync(d1, { recursive: true, force: true });
rmSync(d2, { recursive: true, force: true });
}
});
it('dédoublonne et écarte le cwd primaire des addDirs', () => {
const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-'));
try {
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] });
expect(summary.addedDirs).toEqual([d1]);
} finally {
rmSync(d1, { recursive: true, force: true });
}
});
it('rejette un addDir inexistant (400)', () => {
expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow();
});
it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => {
const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-'));
try {
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' });
// simule la capture du claudeSessionId (normalement résolue via le registre)
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id);
expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' });
expect(manager.groupSessionContext('unknown')).toBeNull();
} finally {
rmSync(d1, { recursive: true, force: true });
}
});
});
}); });

View File

@@ -152,6 +152,22 @@ export interface AddRepoRequest {
repoId: string; repoId: string;
} }
// ---- Session de groupe multi-repo (P6) ----
/** Lance UNE session Claude couvrant tous les repos du groupe (via `--add-dir`). */
export interface CreateGroupSessionRequest {
/** défaut : claude. */
command?: 'claude' | 'bash';
/** présent → couvre le worktree de cette branche dans chaque repo ; absent → les checkouts principaux. */
branch?: string;
}
export interface GroupSessionResponse {
session: SessionSummary;
/** répertoires effectivement couverts par la session (cwd primaire en tête). */
dirs: string[];
/** repos du groupe pour lesquels aucun worktree n'a pu être résolu. */
skipped: Array<{ repoId: string; reason: string }>;
}
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ---- // ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
export interface FsEntry { export interface FsEntry {
name: string; name: string;

View File

@@ -113,6 +113,11 @@ export interface SessionSummary {
waitingFor?: string | null; waitingFor?: string | null;
/** dialogue typé en cours (présent quand activity === 'waiting'). */ /** dialogue typé en cours (présent quand activity === 'waiting'). */
dialog?: SessionDialog | null; dialog?: SessionDialog | null;
// ---- P6 : session de groupe multi-repo (additif) ----
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
addedDirs?: string[];
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
groupId?: string | null;
} }
// ---- Worktrees & repos (P3) ---- // ---- Worktrees & repos (P3) ----

View File

@@ -25,7 +25,7 @@ const { t } = useI18n();
> >
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg> <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
</div> </div>
<span class="font-mono text-[11px] text-zinc-300">payments</span> <span class="font-mono text-[11px] text-zinc-300">{{ t('grpHub') }}</span>
</div> </div>
<div class="relative h-[96px] flex-1"> <div class="relative h-[96px] flex-1">
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true"> <svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">

View File

@@ -29,7 +29,7 @@ export default {
feat6Title: 'Answer without a terminal', feat6Title: 'Answer without a terminal',
feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.', feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.',
feat7Title: 'Work groups', feat7Title: 'Work groups',
feat7Desc: 'Group repos and launch the same feature across all of them at once.', feat7Desc: 'Group repos and run one Claude session across all of them at once.',
feat8Title: 'Local-first & secure', feat8Title: 'Local-first & secure',
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.', feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
scAKicker: 'Worktree-first dashboard', scAKicker: 'Worktree-first dashboard',
@@ -44,10 +44,11 @@ export default {
scCTitle: 'Unblock an agent from the kitchen', scCTitle: 'Unblock an agent from the kitchen',
scCBody: scCBody:
'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.', 'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.',
grpHub: '1 session',
grpKicker: 'Work groups · cross-repo', grpKicker: 'Work groups · cross-repo',
grpTitle: 'Ship one feature across every repo at once', grpTitle: 'Drive every repo from one Claude session',
grpBody: grpBody:
'Group the repos that move together — a service, its client, its docs. Launch the same task to all of them and Arboretum spins up a worktree and an agent in each, then tracks them as one unit.', 'Group the repos that move together — a service, its client, its docs. Launch a single Claude session that spans all of them at once: one conversation, one shared context working across every repo — instead of juggling one agent per repo.',
howKicker: 'How it works', howKicker: 'How it works',
howTitle: 'Zero install. Three steps.', howTitle: 'Zero install. Three steps.',
step1Title: 'Launch the daemon', step1Title: 'Launch the daemon',

View File

@@ -29,7 +29,7 @@ export default {
feat6Title: 'Répondre sans terminal', feat6Title: 'Répondre sans terminal',
feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.', feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.',
feat7Title: 'Work groups', feat7Title: 'Work groups',
feat7Desc: 'Regroupez des repos et lancez la même feature dans tous à la fois.', feat7Desc: 'Regroupez des repos et pilotez-les via une seule session Claude, tous à la fois.',
feat8Title: 'Local-first & sécurisé', feat8Title: 'Local-first & sécurisé',
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.', feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
scAKicker: 'Dashboard worktree-first', scAKicker: 'Dashboard worktree-first',
@@ -44,10 +44,11 @@ export default {
scCTitle: 'Débloquez un agent depuis la cuisine', scCTitle: 'Débloquez un agent depuis la cuisine',
scCBody: scCBody:
"Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.", "Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.",
grpHub: '1 session',
grpKicker: 'Work groups · cross-repo', grpKicker: 'Work groups · cross-repo',
grpTitle: "Livrez une feature dans tous les repos d'un coup", grpTitle: 'Pilotez tous vos repos depuis une seule session Claude',
grpBody: grpBody:
'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez la même tâche à tous ; Arboretum crée un worktree et un agent dans chacun, puis les suit comme un seul ensemble.', 'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez une seule session Claude qui les couvre tous à la fois : une conversation, un contexte partagé travaillant à travers chaque repo — au lieu de jongler avec un agent par repo.',
howKicker: 'Comment ça marche', howKicker: 'Comment ça marche',
howTitle: 'Zéro install. Trois étapes.', howTitle: 'Zéro install. Trois étapes.',
step1Title: 'Lancez le daemon', step1Title: 'Lancez le daemon',

View File

@@ -1,122 +0,0 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.branchLabel') }}
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
</label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.startLabel') }}
<select v-model="startSession" class="input">
<option :value="null">{{ t('crossRepo.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</div>
<div class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.reposLabel') }}
<div class="flex flex-col gap-1">
<label
v-for="repo in repos"
:key="repo.id"
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
{{ statusText(repo.id) }}
</span>
</label>
</div>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
</button>
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
{{ t('crossRepo.retryFailed') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
const props = defineProps<{ repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const groups = useGroupsStore();
const branch = ref('');
const newBranch = ref(true);
const baseRef = ref('');
const startSession = ref<'claude' | 'bash' | null>(null);
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
const results = ref<Record<string, CrossRepoResult>>({});
const running = ref(false);
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
function statusClass(repoId: string): string {
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
}
function statusText(repoId: string): string {
const r = results.value[repoId];
if (!r) return '';
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
}
async function run(repoIds: string[]): Promise<void> {
if (repoIds.length === 0) return;
running.value = true;
for (const id of repoIds) delete results.value[id];
try {
await groups.createCrossRepoFeature(
repoIds,
{
branch: branch.value.trim(),
newBranch: newBranch.value,
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
startSession: startSession.value,
},
(result) => {
results.value = { ...results.value, [result.repoId]: result };
},
);
} finally {
running.value = false;
}
}
function onSubmit(): void {
void run([...selectedRepoIds.value]);
}
function retryFailed(): void {
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
<div class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.modeLabel') }}
<div class="flex flex-wrap gap-3">
<label class="flex items-center gap-1.5">
<input v-model="mode" type="radio" value="feature" :disabled="running" /> {{ t('crossRepo.modeFeature') }}
</label>
<label class="flex items-center gap-1.5">
<input v-model="mode" type="radio" value="main" :disabled="running" /> {{ t('crossRepo.modeMain') }}
</label>
</div>
</div>
<!-- mode feature : branche commune créée dans chaque repo -->
<template v-if="mode === 'feature'">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.branchLabel') }}
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
</label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
</label>
</div>
</template>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.commandLabel') }}
<select v-model="command" class="input">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<div class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('crossRepo.reposLabel') }}
<div class="flex flex-col gap-1">
<div
v-for="repo in repos"
:key="repo.id"
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
{{ repoStatus(repo.id) }}
</span>
</div>
</div>
</div>
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div class="flex items-center gap-2">
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
{{ running ? t('crossRepo.launching') : t('crossRepo.launch') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const groups = useGroupsStore();
const mode = ref<'feature' | 'main'>('feature');
const branch = ref('');
const newBranch = ref(true);
const baseRef = ref('');
const command = ref<'claude' | 'bash'>('claude');
const wtResults = ref<Record<string, CrossRepoResult>>({});
const outcome = ref<GroupFeatureOutcome | null>(null);
const errorMsg = ref<string | null>(null);
const running = ref(false);
const skippedById = computed<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const s of outcome.value?.skipped ?? []) m[s.repoId] = s.reason;
return m;
});
function repoStatus(repoId: string): string {
const wt = wtResults.value[repoId];
if (wt) return wt.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${wt.message ?? ''}`;
const skip = skippedById.value[repoId];
if (skip) return `${t('crossRepo.skippedLabel')}: ${skip}`;
return '';
}
function repoStatusClass(repoId: string): string {
const wt = wtResults.value[repoId];
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
return skippedById.value[repoId] ? 'text-amber-400' : '';
}
async function onSubmit(): Promise<void> {
if (running.value || props.repos.length === 0) return;
if (mode.value === 'feature' && branch.value.trim() === '') return;
running.value = true;
wtResults.value = {};
outcome.value = null;
errorMsg.value = null;
try {
outcome.value = await groups.createGroupFeature(
props.groupId,
props.repos.map((r) => r.id),
{
command: command.value,
...(mode.value === 'feature'
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
: {}),
},
(result) => {
wtResults.value = { ...wtResults.value, [result.repoId]: result };
},
);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
running.value = false;
}
}
</script>

View File

@@ -4,6 +4,13 @@
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1"> <header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
<SessionStateBadge :session="session" /> <SessionStateBadge :session="session" />
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span> <span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
<span
v-if="spannedCount > 1"
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
:title="spannedDirs.join('\n')"
>
{{ t('groups.spanBadge', spannedCount) }}
</span>
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span> <span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
</header> </header>
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" /> <DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
@@ -15,6 +22,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue'; import SessionStateBadge from './SessionStateBadge.vue';
@@ -23,6 +31,11 @@ import TerminalView from './TerminalView.vue';
const props = defineProps<{ session: SessionSummary }>(); const props = defineProps<{ session: SessionSummary }>();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const { t } = useI18n();
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
const spannedCount = computed(() => spannedDirs.value.length);
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd. // libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
const title = computed(() => { const title = computed(() => {

View File

@@ -96,25 +96,29 @@ export default {
viewGrid: 'Terminals', viewGrid: 'Terminals',
gridEmpty: 'No active session in this group.', gridEmpty: 'No active session in this group.',
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.', gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
newFeature: 'New cross-repo feature', newFeature: 'New group session',
spanBadge: 'group · {n} repo | group · {n} repos',
}, },
crossRepo: { crossRepo: {
title: 'New cross-repo feature', title: 'New group session',
intro: 'Create the same worktree across the selected repos, in one action.', intro: 'Launch one Claude session that spans every repo in this group — a single conversation across all of them.',
modeLabel: 'Directories',
modeFeature: 'New branch in each repo',
modeMain: 'Main checkouts',
branchLabel: 'Branch name', branchLabel: 'Branch name',
branchPlaceholder: 'feature/…', branchPlaceholder: 'feature/…',
newBranch: 'create branch', newBranch: 'create branch',
baseRefLabel: 'Base ref (optional)', baseRefLabel: 'Base ref (optional)',
startLabel: 'Start session', commandLabel: 'Session',
startNone: 'no session', reposLabel: 'Repos in this session',
reposLabel: 'Apply to', launch: 'Launch session',
create: 'Create across {n} repos', launching: 'Launching…',
creating: 'Creating…',
retryFailed: 'Retry failed',
close: 'Close', close: 'Close',
pending: 'pending…', ok: 'worktree created',
ok: 'created',
error: 'failed', error: 'failed',
skippedLabel: 'skipped',
done: 'Group session started.',
sessionFailed: 'Could not start the session.',
}, },
repos: { repos: {
add: 'Add repo', add: 'Add repo',

View File

@@ -98,25 +98,29 @@ const fr: typeof en = {
viewGrid: 'Terminaux', viewGrid: 'Terminaux',
gridEmpty: 'Aucune session active dans ce groupe.', gridEmpty: 'Aucune session active dans ce groupe.',
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.', gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
newFeature: 'Nouvelle feature cross-repo', newFeature: 'Nouvelle session de groupe',
spanBadge: 'groupe · {n} dépôt | groupe · {n} dépôts',
}, },
crossRepo: { crossRepo: {
title: 'Nouvelle feature cross-repo', title: 'Nouvelle session de groupe',
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.', intro: 'Lance UNE session Claude couvrant tous les dépôts du groupe — une seule conversation reliant lensemble.',
modeLabel: 'Répertoires',
modeFeature: 'Nouvelle branche dans chaque dépôt',
modeMain: 'Checkouts principaux',
branchLabel: 'Nom de branche', branchLabel: 'Nom de branche',
branchPlaceholder: 'feature/…', branchPlaceholder: 'feature/…',
newBranch: 'créer la branche', newBranch: 'créer la branche',
baseRefLabel: 'Réf. de base (optionnel)', baseRefLabel: 'Réf. de base (optionnel)',
startLabel: 'Démarrer une session', commandLabel: 'Session',
startNone: 'aucune session', reposLabel: 'Dépôts de la session',
reposLabel: 'Appliquer à', launch: 'Lancer la session',
create: 'Créer dans {n} dépôts', launching: 'Lancement…',
creating: 'Création…',
retryFailed: 'Réessayer les échecs',
close: 'Fermer', close: 'Fermer',
pending: 'en cours…', ok: 'worktree créé',
ok: 'créé',
error: 'échec', error: 'échec',
skippedLabel: 'ignoré',
done: 'Session de groupe démarrée.',
sessionFailed: 'Impossible de démarrer la session.',
}, },
repos: { repos: {
add: 'Ajouter un repo', add: 'Ajouter un repo',

View File

@@ -2,8 +2,9 @@ import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import type { import type {
CreateGroupRequest, CreateGroupRequest,
CreateWorktreeRequest, CreateGroupSessionRequest,
GroupResponse, GroupResponse,
GroupSessionResponse,
GroupsListResponse, GroupsListResponse,
GroupSummary, GroupSummary,
RepoSummary, RepoSummary,
@@ -14,17 +15,32 @@ import type {
import { api, ApiError } from '../lib/api'; import { api, ApiError } from '../lib/api';
import { wsClient, type GroupEvent } from '../lib/ws-client'; import { wsClient, type GroupEvent } from '../lib/ws-client';
import { useWorktreesStore } from './worktrees'; import { useWorktreesStore } from './worktrees';
import { useSessionsStore } from './sessions';
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */ /** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
export interface CrossRepoResult { export interface CrossRepoResult {
repoId: string; repoId: string;
status: 'ok' | 'error'; status: 'ok' | 'error';
message?: string; message?: string;
worktreePath?: 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', () => { export const useGroupsStore = defineStore('groups', () => {
@@ -68,7 +84,12 @@ export const useGroupsStore = defineStore('groups', () => {
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId)); return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
} }
function sessionsInGroup(groupId: string): SessionSummary[] { 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> { async function fetchGroups(): Promise<void> {
@@ -109,40 +130,57 @@ export const useGroupsStore = defineStore('groups', () => {
return 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);
}
/** /**
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action. * Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie),
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère * crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo. * 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[], repoIds: string[],
req: CrossRepoRequest, opts: GroupFeatureOptions,
onProgress?: (result: CrossRepoResult) => void, onProgress?: (result: CrossRepoResult) => void,
): Promise<CrossRepoResult[]> { ): Promise<GroupFeatureOutcome> {
const wt = useWorktreesStore(); const wt = useWorktreesStore();
return Promise.all( let worktreeResults: CrossRepoResult[] = [];
repoIds.map(async (repoId): Promise<CrossRepoResult> => { if (opts.branch) {
try { const branch = opts.branch;
const res = await wt.createWorktree(repoId, req); worktreeResults = await Promise.all(
const result: CrossRepoResult = { repoIds.map(async (repoId): Promise<CrossRepoResult> => {
repoId, try {
status: 'ok', const res = await wt.createWorktree(repoId, {
worktreePath: res.worktree.path, branch,
...(res.session ? { sessionId: res.session.id } : {}), newBranch: opts.newBranch ?? true,
}; ...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
onProgress?.(result); startSession: null,
return result; });
} catch (err) { const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path };
const result: CrossRepoResult = { onProgress?.(result);
repoId, return result;
status: 'error', } catch (err) {
message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err), const result: CrossRepoResult = {
}; repoId,
onProgress?.(result); status: 'error',
return result; 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 { function startRealtime(): void {
@@ -168,7 +206,8 @@ export const useGroupsStore = defineStore('groups', () => {
deleteGroup, deleteGroup,
addRepoToGroup, addRepoToGroup,
removeRepoFromGroup, removeRepoFromGroup,
createCrossRepoFeature, createGroupSession,
createGroupFeature,
startRealtime, startRealtime,
stopRealtime, stopRealtime,
}; };

View File

@@ -69,7 +69,7 @@
</template> </template>
</template> </template>
<CrossRepoFeatureModal v-if="showFeatureModal && group" :repos="groupRepos" @close="showFeatureModal = false" /> <GroupSessionModal v-if="showFeatureModal && group" :group-id="group.id" :repos="groupRepos" @close="showFeatureModal = false" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -91,7 +91,7 @@ import SegmentedControl from '../components/ui/SegmentedControl.vue';
import ListToolbar from '../components/ListToolbar.vue'; import ListToolbar from '../components/ListToolbar.vue';
import RepoSection from '../components/RepoSection.vue'; import RepoSection from '../components/RepoSection.vue';
import TerminalGrid from '../components/TerminalGrid.vue'; import TerminalGrid from '../components/TerminalGrid.vue';
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue'; import GroupSessionModal from '../components/GroupSessionModal.vue';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();