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

@@ -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">
<SessionStateBadge :session="session" />
<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>
</header>
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
@@ -15,6 +22,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue';
@@ -23,6 +31,11 @@ import TerminalView from './TerminalView.vue';
const props = defineProps<{ session: SessionSummary }>();
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.
const title = computed(() => {

View File

@@ -96,25 +96,29 @@ export default {
viewGrid: 'Terminals',
gridEmpty: 'No active session in this group.',
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: {
title: 'New cross-repo feature',
intro: 'Create the same worktree across the selected repos, in one action.',
title: 'New group session',
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',
branchPlaceholder: 'feature/…',
newBranch: 'create branch',
baseRefLabel: 'Base ref (optional)',
startLabel: 'Start session',
startNone: 'no session',
reposLabel: 'Apply to',
create: 'Create across {n} repos',
creating: 'Creating…',
retryFailed: 'Retry failed',
commandLabel: 'Session',
reposLabel: 'Repos in this session',
launch: 'Launch session',
launching: 'Launching…',
close: 'Close',
pending: 'pending…',
ok: 'created',
ok: 'worktree created',
error: 'failed',
skippedLabel: 'skipped',
done: 'Group session started.',
sessionFailed: 'Could not start the session.',
},
repos: {
add: 'Add repo',

View File

@@ -98,25 +98,29 @@ const fr: typeof en = {
viewGrid: 'Terminaux',
gridEmpty: 'Aucune session active dans ce groupe.',
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: {
title: 'Nouvelle feature cross-repo',
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
title: 'Nouvelle session de groupe',
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',
branchPlaceholder: 'feature/…',
newBranch: 'créer la branche',
baseRefLabel: 'Réf. de base (optionnel)',
startLabel: 'Démarrer une session',
startNone: 'aucune session',
reposLabel: 'Appliquer à',
create: 'Créer dans {n} dépôts',
creating: 'Création…',
retryFailed: 'Réessayer les échecs',
commandLabel: 'Session',
reposLabel: 'Dépôts de la session',
launch: 'Lancer la session',
launching: 'Lancement…',
close: 'Fermer',
pending: 'en cours…',
ok: 'créé',
ok: 'worktree créé',
error: 'échec',
skippedLabel: 'ignoré',
done: 'Session de groupe démarrée.',
sessionFailed: 'Impossible de démarrer la session.',
},
repos: {
add: 'Ajouter un repo',

View File

@@ -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,
};

View File

@@ -69,7 +69,7 @@
</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>
<script setup lang="ts">
@@ -91,7 +91,7 @@ import SegmentedControl from '../components/ui/SegmentedControl.vue';
import ListToolbar from '../components/ListToolbar.vue';
import RepoSection from '../components/RepoSection.vue';
import TerminalGrid from '../components/TerminalGrid.vue';
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
import GroupSessionModal from '../components/GroupSessionModal.vue';
const { t } = useI18n();
const route = useRoute();