5 Commits

Author SHA1 Message Date
bf9c6ba8f3 release: @johanleroy/git-arboretum 1.6.1
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m0s
CI / Build & test (Node 24) (push) Successful in 9m54s
Deploy site (production) / build-and-deploy (push) Successful in 16s
Release / Publish to Gitea npm registry (push) Successful in 9m46s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m45s
2026-06-22 14:41:36 +02:00
d02c1dabd4 docs(help): refléter la session de groupe unique (P6) + retouche FAQ du site
Le centre d'aide décrivait encore le modèle P5 (une session par repo) ;
l'entrée « cross-repo » dit désormais qu'un groupe lance UNE seule session
Claude couvrant tous ses repos (contexte partagé) et s'ouvre sur son terminal.
Inclut aussi un ajustement de copie de la FAQ du site.
2026-06-22 14:41:29 +02:00
86cdc1f597 fix(web): agrandir les terminaux de la grille sur grand écran (hauteur responsive)
Hauteur de cellule relative au viewport (h-[68dvh] min-h-80
lg:h-[calc((100dvh-9rem)/2)]) au lieu de h-80 fixe : terminaux grands sur PC,
exploitables sur mobile. xterm se re-fit via son ResizeObserver.
2026-06-22 14:41:22 +02:00
1b57c3801c feat(groups): session de groupe au parent commun + redirection vers le terminal
Le cwd d'une session de groupe devient le PARENT COMMUN des répertoires
couverts (racine neutre, chemins relatifs cross-repo naturels) au lieu du
premier repo arbitraire ; chaque repo reste relié en --add-dir. Garde-fou :
si le parent commun est la racine du FS, on retombe sur le premier répertoire.
Nouveau module pur resolveGroupCwd (core/group-session.ts). Côté web, un
lancement réussi redirige vers le terminal plein écran de la session, avec
un toast (succès ou couverture partielle).
2026-06-22 14:41:15 +02:00
4e3b1f5604 fix(sessions): reprendre/forker une session managée morte par UUID (résout le 404)
Le bouton « Reprendre » envoie l'UUID Arboretum, mais resume/fork ne
résolvaient l'id que via la découverte (indexée par claudeSessionId, qui
exclut justement les sessions managées) → 404 systématique après fermeture.
Ajoute PtyManager.resumeTargetById (cwd d'origine + claudeSessionId + groupe
lus en DB par UUID) et un résolveur unifié dans resume ET fork : session
managée morte par UUID, sinon session claude externe découverte par
claudeSessionId. Garde-fous anti-resume d'une session vivante conservés.
2026-06-22 14:41:06 +02:00
15 changed files with 217 additions and 22 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "1.6.0", "version": "1.6.1",
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them", "description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -144,6 +144,37 @@ try {
const out = c.state.outputs.get(att?.channel) ?? ''; const out = c.state.outputs.get(att?.channel) ?? '';
check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120)); check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120));
// --- Régression « Reprendre » : reprise d'une session MANAGÉE morte par son UUID Arboretum ---
// (le bouton web envoie l'UUID managé, pas le claudeSessionId ; le serveur doit le résoudre en DB).
const createManaged = await fetch(`${ORIGIN}/api/v1/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie },
body: JSON.stringify({ cwd: workDir, command: 'claude' }),
});
const managed = (await createManaged.json()).session;
check('création session managée claude → 201 (pid)', createManaged.status === 201 && managed?.pid > 0);
// Simule la capture du claudeSessionId : entrée registre pour le pid du PTY managé (pollée toutes les 400ms).
writeFileSync(
join(sessions, `${managed.pid}.json`),
JSON.stringify({ pid: managed.pid, sessionId: 'sid-managed', cwd: workDir, status: 'idle' }),
);
const captured = await c.waitMsg(
(m) => m.type === 'session_update' && m.session?.id === managed.id && m.session?.claudeSessionId === 'sid-managed',
);
check('claudeSessionId capturé depuis le registre (session managée)', !!captured);
// Fermeture (kill) puis reprise par UUID managé → 201 dans le cwd d'origine (avant le fix : 404).
const killManaged = await fetch(`${ORIGIN}/api/v1/sessions/${managed.id}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } });
check('kill session managée → 200', killManaged.status === 200);
await sleep(400); // laisse handleExit persister ended_at
const resumeManaged = await postJson(`/api/v1/sessions/${managed.id}/resume`, cookie);
const resumedManaged = (await resumeManaged.json()).session;
check(
'resume dune session MANAGÉE morte par UUID → 201',
resumeManaged.status === 201 && resumedManaged?.command === 'claude' && resumedManaged?.source === 'managed' && resumedManaged?.cwd === workDir,
);
// Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique. // Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique.
writeJsonl(workDir, 'sid-new'); writeJsonl(workDir, 'sid-new');
const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000); const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000);

View File

@@ -127,9 +127,11 @@ try {
const gsession = gsBody.session; const gsession = gsBody.session;
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession); 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 : 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); // cwd = parent commun des repos (P6), chaque repo relié en --add-dir → 2 addedDirs.
check('session de groupe : cwd = parent commun des repos', gsession?.cwd === tmp);
check('session de groupe : addedDirs = les 2 repos', (gsession?.addedDirs?.length ?? 0) === 2);
check('session de groupe : groupId posé', gsession?.groupId === group.id); 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); check('session de groupe : cwd parent + 2 repos addedDirs = 3 chemins distincts', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 3);
// La session apparaît dans la liste globale avec son groupId. // La session apparaît dans la liste globale avec son groupId.
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json(); const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();

View File

@@ -0,0 +1,37 @@
import { parse, resolve, sep } from 'node:path';
/**
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
* de chaîne — `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
*/
export function commonAncestorDir(paths: string[]): string {
const first = paths[0];
if (first === undefined) throw new Error('commonAncestorDir: empty input');
const firstSegs = resolve(first).split(sep);
let common = firstSegs.length;
for (let i = 1; i < paths.length; i++) {
const segs = resolve(paths[i]!).split(sep);
let k = 0;
while (k < common && k < segs.length && segs[k] === firstSegs[k]) k++;
common = k;
}
const joined = firstSegs.slice(0, common).join(sep);
// `['', 'a', 'b'].slice(0,1).join('/')` === '' → racine POSIX ; rétablir le séparateur racine.
return joined === '' ? parse(resolve(first)).root : joined;
}
/**
* Politique de répertoire de travail d'une session de groupe (P6) : le `cwd` est le PARENT COMMUN
* des répertoires couverts (racine neutre, chemins relatifs cross-repo naturels), chaque répertoire
* étant ensuite relié via `--add-dir` (le dédoublonnage + filtrage du cwd est fait par `PtyManager`).
* Garde-fou : si le parent commun est la racine du FS (repos éparpillés sur des racines différentes),
* on retombe sur le premier répertoire pour ne pas accorder à Claude la permission sur tout le disque.
*/
export function resolveGroupCwd(dirs: string[]): { cwd: string; addDirs: string[] } {
const first = dirs[0];
if (first === undefined) throw new Error('resolveGroupCwd: empty input');
const ancestor = commonAncestorDir(dirs);
const root = parse(resolve(first)).root;
const cwd = ancestor === root ? first : ancestor;
return { cwd, addDirs: dirs };
}

View File

@@ -201,6 +201,24 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id }; return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
} }
/**
* Cible de reprise d'une session managée MORTE (P2/P6), résolue par UUID Arboretum : son cwd
* d'origine, son claudeSessionId et son contexte de groupe, lus en DB. null si l'id ne correspond
* pas à une session managée morte, reprenable (claude + claudeSessionId connu).
* Complète `DiscoveryService.getDiscovered`, qui ne couvre QUE les sessions claude EXTERNES
* (une managée connue est justement exclue de la découverte).
*/
resumeTargetById(id: string): { cwd: string; claudeSessionId: string; addedDirs: string[]; groupId: string | null } | null {
if (this.live.has(id)) return null; // vivante : pas de resume direct (fork via le même chemin)
const row = this.db
.prepare(
"SELECT cwd, claude_session_id, added_dirs, group_id FROM sessions WHERE id = ? AND ended_at IS NOT NULL AND claude_session_id IS NOT NULL AND command = 'claude'",
)
.get(id) as { cwd: string; claude_session_id: string; added_dirs: string | null; group_id: string | null } | undefined;
if (!row) return null;
return { cwd: row.cwd, claudeSessionId: row.claude_session_id, 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()) {

View File

@@ -13,6 +13,7 @@ import type { WorktreeManager } from '../core/worktree-manager.js';
import type { PtyManager } from '../core/pty-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 { resolveGroupCwd } from '../core/group-session.js';
import { sendManagerError } from './repos.js'; import { sendManagerError } from './repos.js';
export function registerGroupRoutes( export function registerGroupRoutes(
@@ -137,13 +138,14 @@ export function registerGroupRoutes(
if (!dirs.includes(match.path)) dirs.push(match.path); if (!dirs.includes(match.path)) dirs.push(match.path);
} }
const [primary, ...rest] = dirs; if (dirs.length === 0) {
if (primary === undefined) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } }); return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
} }
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
const { cwd, addDirs } = resolveGroupCwd(dirs);
try { try {
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id }); const session = manager.spawn({ cwd, addDirs, command, groupId: id });
recordAudit(db, { recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown', actor: req.authContext?.tokenId ?? 'unknown',
action: 'group.session.create', action: 'group.session.create',

View File

@@ -26,24 +26,29 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
} }
}); });
// Reprise d'une session morte : nouveau PTY managé `--resume <id>` DANS SON CWD D'ORIGINE (spike S1). // Reprise d'une session morte : nouveau PTY managé `--resume <claudeSessionId>` DANS SON CWD D'ORIGINE
// Le cwd n'est jamais fourni par le client : il est lu sur disque via la découverte. // (spike S1). Le cwd n'est JAMAIS fourni par le client. Deux origines d'id possibles :
// - session managée morte (id = UUID Arboretum) → cwd + claudeSessionId + groupe lus en DB ;
// - session claude EXTERNE découverte (id = claudeSessionId) → cwd lu sur disque.
app.post('/api/v1/sessions/:id/resume', async (req, reply) => { app.post('/api/v1/sessions/:id/resume', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const discovered = discovery.getDiscovered(id); const managed = manager.resumeTargetById(id);
if (!discovered) { const discovered = managed ? null : discovery.getDiscovered(id);
if (!managed && !discovered) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } }); return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } });
} }
const cwd = managed ? managed.cwd : discovered!.cwd;
const claudeSessionId = managed ? managed.claudeSessionId : id;
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE). // Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
if (discovery.isClaudeSessionLive(id) || manager.findLiveByClaudeSessionId(id)) { if (discovery.isClaudeSessionLive(claudeSessionId) || manager.findLiveByClaudeSessionId(claudeSessionId)) {
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 {
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume. // Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
const ctx = manager.groupSessionContext(id); const ctx = managed ? { addedDirs: managed.addedDirs, groupId: managed.groupId } : manager.groupSessionContext(claudeSessionId);
const session = manager.spawn({ const session = manager.spawn({
cwd: discovered.cwd, cwd,
resume: { claudeSessionId: id }, resume: { claudeSessionId },
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}), ...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
...(ctx?.groupId ? { groupId: ctx.groupId } : {}), ...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
}); });
@@ -56,14 +61,18 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
}); });
// Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume <id> --fork-session`). // Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume <id> --fork-session`).
// Même résolution d'id que /resume (managée morte par UUID, sinon externe découverte par claudeSessionId).
app.post('/api/v1/sessions/:id/fork', async (req, reply) => { app.post('/api/v1/sessions/:id/fork', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const discovered = discovery.getDiscovered(id); const managed = manager.resumeTargetById(id);
if (!discovered) { const discovered = managed ? null : discovery.getDiscovered(id);
if (!managed && !discovered) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } }); return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } });
} }
const cwd = managed ? managed.cwd : discovered!.cwd;
const claudeSessionId = managed ? managed.claudeSessionId : id;
try { try {
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id, fork: true } }); const session = manager.spawn({ cwd, resume: { claudeSessionId, fork: true } });
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,45 @@
import { describe, expect, it } from 'vitest';
import { commonAncestorDir, resolveGroupCwd } from '../src/core/group-session.js';
describe('commonAncestorDir', () => {
it('renvoie le chemin lui-même pour un seul répertoire', () => {
expect(commonAncestorDir(['/home/johan/WebstormProjects/arboretum'])).toBe('/home/johan/WebstormProjects/arboretum');
});
it('renvoie le parent commun de deux siblings', () => {
expect(commonAncestorDir(['/p/a', '/p/b'])).toBe('/p');
});
it('gère un répertoire ancêtre dun autre', () => {
expect(commonAncestorDir(['/p/a', '/p/a/b'])).toBe('/p/a');
});
it('compare par segments, pas par préfixe de chaîne', () => {
// /a/bc n'est PAS un ancêtre de /a/bcd : ancêtre commun = /a
expect(commonAncestorDir(['/a/bc', '/a/bcd'])).toBe('/a');
});
it('renvoie la racine quand aucun segment nest commun', () => {
expect(commonAncestorDir(['/x/a', '/y/b'])).toBe('/');
});
});
describe('resolveGroupCwd', () => {
it('mono-repo : cwd = le repo, addDirs sera filtré à vide par PtyManager', () => {
const { cwd, addDirs } = resolveGroupCwd(['/p/a']);
expect(cwd).toBe('/p/a');
expect(addDirs).toEqual(['/p/a']);
});
it('multi-repos sous un parent commun : cwd = parent, addDirs = tous les repos', () => {
const dirs = ['/home/johan/WebstormProjects/beehelp_lambdas', '/home/johan/WebstormProjects/beehelp_api'];
const { cwd, addDirs } = resolveGroupCwd(dirs);
expect(cwd).toBe('/home/johan/WebstormProjects');
expect(addDirs).toEqual(dirs);
});
it('garde-fou : repos sur des racines différentes → retombe sur le premier répertoire', () => {
const { cwd } = resolveGroupCwd(['/x/a', '/y/b']);
expect(cwd).toBe('/x/a');
});
});

View File

@@ -187,6 +187,34 @@ describe('PtyManager (pty mocké)', () => {
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' }); expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
}); });
it('resumeTargetById : session claude morte → cwd + claudeSessionId + groupe ; null sinon', () => {
const d1 = mkdtempSync(join(tmpdir(), 'arb-rt-'));
try {
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpR' });
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-resume', summary.id);
// vivante → null (resume direct interdit ; fork passe par le même chemin)
expect(manager.resumeTargetById(summary.id)).toBeNull();
lastPty().emitExit(0);
// morte avec claudeSessionId connu → cible complète
expect(manager.resumeTargetById(summary.id)).toEqual({
cwd,
claudeSessionId: 'cs-resume',
addedDirs: [d1],
groupId: 'grpR',
});
// id inconnu → null
expect(manager.resumeTargetById('nope')).toBeNull();
} finally {
rmSync(d1, { recursive: true, force: true });
}
});
it('resumeTargetById : session bash morte (sans claudeSessionId) → null', () => {
const { summary, pty } = spawnBash();
pty.emitExit(0);
expect(manager.resumeTargetById(summary.id)).toBeNull();
});
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => { it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-')); const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));

View File

@@ -68,7 +68,7 @@ export default {
sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.', sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.',
sec4Title: 'Distant via Tailscale', sec4Title: 'Distant via Tailscale',
sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.', sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.',
faqTitle: 'Vos questions, nos réponses', faqTitle: 'Vos questions, les réponses',
ctaTitle: 'Prenez le commandement de vos agents', ctaTitle: 'Prenez le commandement de vos agents',
ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.', ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.',
ctaSource: 'Voir le code sur Gitea', ctaSource: 'Voir le code sur Gitea',

View File

@@ -79,15 +79,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared'; import type { RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups'; import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
import { useToastsStore } from '../stores/toasts';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>(); const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const groups = useGroupsStore(); const groups = useGroupsStore();
const toasts = useToastsStore();
const mode = ref<'feature' | 'main'>('feature'); const mode = ref<'feature' | 'main'>('feature');
const branch = ref(''); const branch = ref('');
@@ -139,6 +143,14 @@ async function onSubmit(): Promise<void> {
wtResults.value = { ...wtResults.value, [result.repoId]: result }; wtResults.value = { ...wtResults.value, [result.repoId]: result };
}, },
); );
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
if (outcome.value.session) {
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
else toasts.success(t('toast.groupSessionLaunched'));
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
}
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err); errorMsg.value = err instanceof Error ? err.message : String(err);
} finally { } finally {

View File

@@ -6,9 +6,16 @@
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }} {{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
</p> </p>
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux. <!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
Sur mobile la grille retombe à une colonne (empilement). --> Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
Plancher min-h-80 pour les fenêtres courtes. -->
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3"> <div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
<TerminalCell v-for="s in shown" :key="s.id" :session="s" class="h-80" /> <TerminalCell
v-for="s in shown"
:key="s.id"
:session="s"
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
/>
</div> </div>
</template> </template>
</div> </div>

View File

@@ -306,6 +306,8 @@ export default {
groupCreated: 'Group created', groupCreated: 'Group created',
groupUpdated: 'Group updated', groupUpdated: 'Group updated',
groupDeleted: 'Group deleted', groupDeleted: 'Group deleted',
groupSessionLaunched: 'Group session started',
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
genericError: 'Something went wrong', genericError: 'Something went wrong',
dismiss: 'Dismiss', dismiss: 'Dismiss',
}, },

View File

@@ -309,6 +309,8 @@ const fr: typeof en = {
groupCreated: 'Groupe créé', groupCreated: 'Groupe créé',
groupUpdated: 'Groupe mis à jour', groupUpdated: 'Groupe mis à jour',
groupDeleted: 'Groupe supprimé', groupDeleted: 'Groupe supprimé',
groupSessionLaunched: 'Session de groupe démarrée',
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
genericError: 'Une erreur est survenue', genericError: 'Une erreur est survenue',
dismiss: 'Fermer', dismiss: 'Fermer',
}, },

View File

@@ -122,7 +122,7 @@ const en: HelpSection[] = [
}, },
{ {
title: 'Cross-repo feature', title: 'Cross-repo feature',
body: 'Create the same worktree (and an optional session) across every repo of the group in one action; partial failures can be retried per repo.', body: 'Create the same branch worktree across every repo of the group, then launch one Claude session that spans them all — one conversation, one shared context. Partial worktree failures can be retried per repo, and the new session opens straight into its terminal.',
}, },
], ],
}, },
@@ -290,7 +290,7 @@ const fr: HelpSection[] = [
}, },
{ {
title: 'Feature cross-repo', title: 'Feature cross-repo',
body: 'Créez le même worktree (et une session optionnelle) dans chaque dépôt du groupe en une seule action ; les échecs partiels sont rejouables dépôt par dépôt.', body: 'Créez le même worktree de branche dans chaque dépôt du groupe, puis lancez une seule session Claude qui les couvre tous — une conversation, un contexte partagé. Les échecs partiels de worktree sont rejouables dépôt par dépôt, et la session souvre directement sur son terminal.',
}, },
], ],
}, },