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:
@@ -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>
|
||||
148
packages/web/src/components/GroupSessionModal.vue
Normal file
148
packages/web/src/components/GroupSessionModal.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user