Compare commits
7 Commits
afe39ac072
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e84c6a7f6d | |||
| dacff2b98f | |||
| bf9c6ba8f3 | |||
| d02c1dabd4 | |||
| 86cdc1f597 | |||
| 1b57c3801c | |||
| 4e3b1f5604 |
@@ -28,8 +28,8 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
|||||||
|
|
||||||
- **Dashboard worktree-first, multi-repo** — chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
- **Dashboard worktree-first, multi-repo** — chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
||||||
- **Cycle de vie complet des worktrees** — créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
- **Cycle de vie complet des worktrees** — créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
||||||
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante.
|
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
||||||
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur.
|
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
||||||
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
||||||
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
|||||||
|
|
||||||
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
||||||
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
||||||
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session.
|
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
||||||
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
||||||
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
||||||
- **Work groups** — bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
- **Work groups** — bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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 d’une 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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
const push = new PushService(db, config.vapidContact, undefined, box);
|
const push = new PushService(db, config.vapidContact, undefined, box);
|
||||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||||
const discovery = new DiscoveryService({
|
const discovery = new DiscoveryService({
|
||||||
|
db,
|
||||||
ptyManager: manager,
|
ptyManager: manager,
|
||||||
projectsDir: config.claudeProjectsDir,
|
projectsDir: config.claudeProjectsDir,
|
||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
@@ -168,7 +169,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, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { join } from 'node:path';
|
|||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
||||||
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
||||||
|
import { listHiddenSessionIds, type Db } from '../db/index.js';
|
||||||
import type { PtyManager } from './pty-manager.js';
|
import type { PtyManager } from './pty-manager.js';
|
||||||
|
|
||||||
const DEFAULT_REFRESH_MS = 10_000;
|
const DEFAULT_REFRESH_MS = 10_000;
|
||||||
@@ -17,6 +18,7 @@ export interface DiscoveryServiceEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveryOptions {
|
export interface DiscoveryOptions {
|
||||||
|
db: Db;
|
||||||
ptyManager: PtyManager;
|
ptyManager: PtyManager;
|
||||||
projectsDir?: string;
|
projectsDir?: string;
|
||||||
sessionsDir?: string;
|
sessionsDir?: string;
|
||||||
@@ -24,6 +26,7 @@ export interface DiscoveryOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
||||||
|
private readonly db: Db;
|
||||||
private readonly projectsDir: string;
|
private readonly projectsDir: string;
|
||||||
private readonly sessionsDir: string;
|
private readonly sessionsDir: string;
|
||||||
private readonly ptyManager: PtyManager;
|
private readonly ptyManager: PtyManager;
|
||||||
@@ -35,6 +38,7 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
|
|
||||||
constructor(opts: DiscoveryOptions) {
|
constructor(opts: DiscoveryOptions) {
|
||||||
super();
|
super();
|
||||||
|
this.db = opts.db;
|
||||||
this.ptyManager = opts.ptyManager;
|
this.ptyManager = opts.ptyManager;
|
||||||
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
||||||
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
||||||
@@ -81,6 +85,9 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
||||||
}
|
}
|
||||||
const known = this.ptyManager.knownClaudeSessionIds();
|
const known = this.ptyManager.knownClaudeSessionIds();
|
||||||
|
// Sessions masquées par l'utilisateur : on les garde dans le cache (resume/fork possibles) mais
|
||||||
|
// marquées `hidden` → la route /sessions les exclut par défaut.
|
||||||
|
const hiddenIds = listHiddenSessionIds(this.db);
|
||||||
|
|
||||||
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
||||||
const latest = new Map<string, DiscoveredJsonl>();
|
const latest = new Map<string, DiscoveredJsonl>();
|
||||||
@@ -113,6 +120,7 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
||||||
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
||||||
registryStatus: r?.status ?? null,
|
registryStatus: r?.status ?? null,
|
||||||
|
hidden: hiddenIds.has(d.claudeSessionId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
packages/server/src/core/group-session.ts
Normal file
37
packages/server/src/core/group-session.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Masquage des sessions Claude découvertes (lancées en CLI hors Arboretum) qui polluent la liste.
|
||||||
|
// Calqué sur repos.hidden (#6) : la session masquée reste connue (resume/fork possibles) mais est
|
||||||
|
// exclue de la liste par défaut, et un re-scan ne la ressuscite pas. Clé = claudeSessionId (stable,
|
||||||
|
// partagé entre une découverte et sa reprise managée).
|
||||||
|
id: 9,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE hidden_sessions (
|
||||||
|
claude_session_id TEXT PRIMARY KEY,
|
||||||
|
hidden_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
@@ -185,3 +198,23 @@ export function getSetting(db: Db, key: string): string | null {
|
|||||||
export function setSetting(db: Db, key: string, value: string): void {
|
export function setSetting(db: Db, key: string, value: string): void {
|
||||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Sessions masquées (par claudeSessionId) ----
|
||||||
|
|
||||||
|
/** Ensemble des claudeSessionId masqués par l'utilisateur. */
|
||||||
|
export function listHiddenSessionIds(db: Db): Set<string> {
|
||||||
|
const rows = db.prepare('SELECT claude_session_id FROM hidden_sessions').all() as Array<{ claude_session_id: string }>;
|
||||||
|
return new Set(rows.map((r) => r.claude_session_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masque une session (idempotent). */
|
||||||
|
export function hideSession(db: Db, claudeSessionId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO hidden_sessions (claude_session_id, hidden_at) VALUES (?, ?) ON CONFLICT(claude_session_id) DO NOTHING',
|
||||||
|
).run(claudeSessionId, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ré-affiche une session masquée (idempotent). */
|
||||||
|
export function unhideSession(db: Db, claudeSessionId: string): void {
|
||||||
|
db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||||
import type { PtyManager } from '../core/pty-manager.js';
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
|
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
import { hideSession, unhideSession, type Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService): void {
|
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService, db: Db): void {
|
||||||
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
app.get('/api/v1/sessions', async (req): Promise<SessionsListResponse> => {
|
||||||
return { sessions: mergeSessions(manager.list(), discovery.list()) };
|
const includeHidden = (req.query as { includeHidden?: string }).includeHidden === 'true';
|
||||||
|
const all = mergeSessions(manager.list(), discovery.list());
|
||||||
|
return { sessions: includeHidden ? all : all.filter((s) => !s.hidden) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Résout le claudeSessionId d'un :id de session (managée par UUID, ou découverte dont l'id EST le sid).
|
||||||
|
const resolveClaudeSid = (id: string): string | null => {
|
||||||
|
const managed = manager.resumeTargetById(id);
|
||||||
|
if (managed?.claudeSessionId) return managed.claudeSessionId;
|
||||||
|
return discovery.getDiscovered(id) ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
app.post('/api/v1/sessions', async (req, reply) => {
|
app.post('/api/v1/sessions', async (req, reply) => {
|
||||||
const body = req.body as Partial<CreateSessionRequest> | null;
|
const body = req.body as Partial<CreateSessionRequest> | null;
|
||||||
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
||||||
@@ -26,24 +37,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 +72,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) {
|
||||||
@@ -72,6 +92,49 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Masque une session découverte (ou managée morte) : exclue de la liste sauf ?includeHidden=true.
|
||||||
|
// Reste reprenable/forkable — c'est un filtre d'affichage, pas une suppression.
|
||||||
|
app.post('/api/v1/sessions/:id/hide', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const sid = resolveClaudeSid(id);
|
||||||
|
if (!sid) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to hide' } });
|
||||||
|
}
|
||||||
|
hideSession(db, sid);
|
||||||
|
await discovery.refresh(); // rediffuse l'état (hidden) aux clients
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.hide', resourceId: sid });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ré-affiche une session masquée.
|
||||||
|
app.delete('/api/v1/sessions/:id/hide', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const sid = resolveClaudeSid(id);
|
||||||
|
if (!sid) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to unhide' } });
|
||||||
|
}
|
||||||
|
unhideSession(db, sid);
|
||||||
|
await discovery.refresh();
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.unhide', resourceId: sid });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Masquage de masse : nettoie d'un coup tout l'historique externe actuellement visible. Les futures
|
||||||
|
// sessions externes réapparaîtront (sinon --no-discover). Renvoie le nombre de sessions masquées.
|
||||||
|
app.post('/api/v1/sessions/hide-discovered', async (req, reply) => {
|
||||||
|
const sids = discovery
|
||||||
|
.list()
|
||||||
|
.filter((s) => !s.hidden && s.claudeSessionId)
|
||||||
|
.map((s) => s.claudeSessionId as string);
|
||||||
|
for (const sid of sids) hideSession(db, sid);
|
||||||
|
await discovery.refresh();
|
||||||
|
if (sids.length > 0) {
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.hideDiscovered', resourceId: null, details: { count: sids.length } });
|
||||||
|
}
|
||||||
|
const res: HideDiscoveredResponse = { hidden: sids.length };
|
||||||
|
return reply.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
if (!manager.kill(id)) {
|
if (!manager.kill(id)) {
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ describe('app e2e — auth, origin et sessions', () => {
|
|||||||
expect(res.json()).toEqual({ sessions: [] });
|
expect(res.json()).toEqual({ sessions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('masquage des sessions : hide-discovered (vide), includeHidden, hide d’un id inconnu → 404', async () => {
|
||||||
|
const headers = { authorization: `Bearer ${t.token}` };
|
||||||
|
const mass = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/hide-discovered', headers });
|
||||||
|
expect(mass.statusCode).toBe(200);
|
||||||
|
expect(mass.json()).toEqual({ hidden: 0 });
|
||||||
|
|
||||||
|
const withHidden = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions?includeHidden=true', headers });
|
||||||
|
expect(withHidden.statusCode).toBe(200);
|
||||||
|
expect(withHidden.json()).toEqual({ sessions: [] });
|
||||||
|
|
||||||
|
const unknown = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/hide', headers });
|
||||||
|
expect(unknown.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
||||||
const res = await t.bundle.app.inject({
|
const res = await t.bundle.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.j
|
|||||||
import { munge } from '../src/core/jsonl-discovery.js';
|
import { munge } from '../src/core/jsonl-discovery.js';
|
||||||
import { readProcStart } from '../src/core/session-registry.js';
|
import { readProcStart } from '../src/core/session-registry.js';
|
||||||
import { PtyManager } from '../src/core/pty-manager.js';
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
import { openDb, hideSession, unhideSession, listHiddenSessionIds, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
|
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
|
||||||
const dir = join(projectsDir, munge(cwd));
|
const dir = join(projectsDir, munge(cwd));
|
||||||
@@ -30,7 +30,7 @@ describe('DiscoveryService', () => {
|
|||||||
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||||
db = openDb(':memory:');
|
db = openDb(':memory:');
|
||||||
manager = new PtyManager(db, sessionsDir);
|
manager = new PtyManager(db, sessionsDir);
|
||||||
svc = new DiscoveryService({ ptyManager: manager, projectsDir, sessionsDir });
|
svc = new DiscoveryService({ db, ptyManager: manager, projectsDir, sessionsDir });
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
svc.stop();
|
svc.stop();
|
||||||
@@ -71,6 +71,29 @@ describe('DiscoveryService', () => {
|
|||||||
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
|
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marque hidden une session masquée mais la garde résoluble (resume/fork)', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/old', 'old-sid');
|
||||||
|
hideSession(db, 'old-sid');
|
||||||
|
await svc.refresh();
|
||||||
|
const s = svc.list().find((x) => x.id === 'old-sid');
|
||||||
|
expect(s?.hidden).toBe(true);
|
||||||
|
// toujours connue : la reprise/le fork doivent rester possibles
|
||||||
|
expect(svc.getDiscovered('old-sid')?.cwd).toBe('/home/u/old');
|
||||||
|
// ré-affichage
|
||||||
|
unhideSession(db, 'old-sid');
|
||||||
|
await svc.refresh();
|
||||||
|
expect(svc.list().find((x) => x.id === 'old-sid')?.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hideSession est idempotent et listHiddenSessionIds reflète l’état', () => {
|
||||||
|
hideSession(db, 'a');
|
||||||
|
hideSession(db, 'a');
|
||||||
|
hideSession(db, 'b');
|
||||||
|
expect(listHiddenSessionIds(db)).toEqual(new Set(['a', 'b']));
|
||||||
|
unhideSession(db, 'a');
|
||||||
|
expect(listHiddenSessionIds(db)).toEqual(new Set(['b']));
|
||||||
|
});
|
||||||
|
|
||||||
it('émet discovery_update sur changement uniquement', async () => {
|
it('émet discovery_update sur changement uniquement', async () => {
|
||||||
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
|
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
|
||||||
const seen: SessionSummary[] = [];
|
const seen: SessionSummary[] = [];
|
||||||
|
|||||||
45
packages/server/test/group-cwd.test.ts
Normal file
45
packages/server/test/group-cwd.test.ts
Normal 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 d’un 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 n’est 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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-'));
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('WorktreeManager', () => {
|
|||||||
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
||||||
dirs.push(claudeHome);
|
dirs.push(claudeHome);
|
||||||
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
||||||
discovery = new DiscoveryService({ ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
discovery = new DiscoveryService({ db, ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
||||||
wt = new WorktreeManager(db, pty, discovery);
|
wt = new WorktreeManager(db, pty, discovery);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface SessionsListResponse {
|
|||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
}
|
}
|
||||||
|
/** POST /sessions/hide-discovered — masque tout l'historique de sessions externes visible. */
|
||||||
|
export interface HideDiscoveredResponse {
|
||||||
|
/** nombre de sessions effectivement masquées. */
|
||||||
|
hidden: number;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /sessions/:id/resume et /sessions/:id/fork (P2) : aucun corps — cwd et command
|
// POST /sessions/:id/resume et /sessions/:id/fork (P2) : aucun corps — cwd et command
|
||||||
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export interface SessionSummary {
|
|||||||
addedDirs?: string[];
|
addedDirs?: string[];
|
||||||
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
// ---- Masquage (additif) ----
|
||||||
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worktrees & repos (P3) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ export default {
|
|||||||
scAKicker: 'Worktree-first dashboard',
|
scAKicker: 'Worktree-first dashboard',
|
||||||
scATitle: 'See what needs you — before anything stalls',
|
scATitle: 'See what needs you — before anything stalls',
|
||||||
scABody:
|
scABody:
|
||||||
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere.',
|
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere. Hide the noisy history left by past terminal sessions so the list stays actionable.',
|
||||||
scBKicker: 'Multi-terminal grid',
|
scBKicker: 'Multi-terminal grid',
|
||||||
scBTitle: "Every agent's output, streaming live",
|
scBTitle: "Every agent's output, streaming live",
|
||||||
scBBody:
|
scBBody:
|
||||||
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over, answer a prompt, or stop a run.',
|
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over in a fullscreen terminal — prompt pinned to the bottom, full scrollback above — answer a prompt, or stop a run.',
|
||||||
scCKicker: 'On your phone',
|
scCKicker: 'On your phone',
|
||||||
scCTitle: 'Unblock an agent from the kitchen',
|
scCTitle: 'Unblock an agent from the kitchen',
|
||||||
scCBody:
|
scCBody:
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ export default {
|
|||||||
scAKicker: 'Dashboard worktree-first',
|
scAKicker: 'Dashboard worktree-first',
|
||||||
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
||||||
scABody:
|
scABody:
|
||||||
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs.",
|
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs. Masquez l'historique encombrant laissé par vos anciennes sessions terminal pour garder la liste actionnable.",
|
||||||
scBKicker: 'Grille multi-terminaux',
|
scBKicker: 'Grille multi-terminaux',
|
||||||
scBTitle: 'La sortie de chaque agent, en direct',
|
scBTitle: 'La sortie de chaque agent, en direct',
|
||||||
scBBody:
|
scBBody:
|
||||||
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main, répondre, ou arrêter une exécution.",
|
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main dans un terminal plein écran — invite ancrée en bas, historique défilable au-dessus — répondre, ou arrêter une exécution.",
|
||||||
scCKicker: 'Sur votre téléphone',
|
scCKicker: 'Sur votre téléphone',
|
||||||
scCTitle: 'Débloquez un agent depuis la cuisine',
|
scCTitle: 'Débloquez un agent depuis la cuisine',
|
||||||
scCBody:
|
scCBody:
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -4,18 +4,21 @@
|
|||||||
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
||||||
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
|
||||||
|
proprement au lieu d'être tronqué/collé au suivant comme avec un flex-wrap horizontal. -->
|
||||||
|
<div class="mt-2 flex flex-col gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-for="opt in dialog.options"
|
v-for="opt in dialog.options"
|
||||||
:key="opt.n"
|
:key="opt.n"
|
||||||
class="btn text-xs"
|
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
|
||||||
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="respond('select', opt.n)"
|
@click="respond('select', opt.n)"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-amber-400">{{ opt.n }}</span> {{ opt.label }}
|
<span class="font-mono text-amber-400">{{ opt.n }}</span>
|
||||||
|
<span class="min-w-0 flex-1">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-danger text-xs" :disabled="busy" @click="respond('deny')">
|
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
||||||
{{ t('sessions.dialog.deny') }}
|
{{ t('sessions.dialog.deny') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative min-h-0 bg-[#09090b]">
|
<div class="relative h-full min-h-0 bg-[#09090b]">
|
||||||
<div ref="container" class="h-full w-full" />
|
<div ref="container" class="h-full w-full" />
|
||||||
<div
|
<div
|
||||||
v-if="!controlling && !ended"
|
v-if="!controlling && !ended"
|
||||||
@@ -71,6 +71,19 @@ onMounted(async () => {
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
const activeTerm = term;
|
const activeTerm = term;
|
||||||
|
|
||||||
|
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa
|
||||||
|
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
||||||
|
// lignes et déforme le TUI. On refait donc le calcul en `requestAnimationFrame`, puis on resynchronise
|
||||||
|
// les dimensions PTY. Sert aussi après un resync (reconnexion) où l'écran est remis à plat.
|
||||||
|
const refit = (): void => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
/* conteneur sans dimension (démontage en cours) : ignoré */
|
||||||
|
}
|
||||||
|
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
attachment = await wsClient.attach({
|
attachment = await wsClient.attach({
|
||||||
sessionId: props.sessionId,
|
sessionId: props.sessionId,
|
||||||
@@ -79,7 +92,10 @@ onMounted(async () => {
|
|||||||
rows: activeTerm.rows,
|
rows: activeTerm.rows,
|
||||||
sink: {
|
sink: {
|
||||||
write: (data, callback) => activeTerm.write(data, callback),
|
write: (data, callback) => activeTerm.write(data, callback),
|
||||||
reset: () => activeTerm.reset(),
|
reset: () => {
|
||||||
|
activeTerm.reset();
|
||||||
|
requestAnimationFrame(refit);
|
||||||
|
},
|
||||||
onDetached: (reason) => {
|
onDetached: (reason) => {
|
||||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||||
},
|
},
|
||||||
@@ -99,12 +115,10 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controlling.value = attachment.controlling;
|
controlling.value = attachment.controlling;
|
||||||
|
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
||||||
|
|
||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(refit);
|
||||||
fit.fit();
|
|
||||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
|
||||||
});
|
|
||||||
resizeObserver.observe(container.value);
|
resizeObserver.observe(container.value);
|
||||||
if (props.mode === 'interactive') activeTerm.focus();
|
if (props.mode === 'interactive') activeTerm.focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
|
<!-- fullbleed (terminal) : pas de tabbar mobile ni de padding bas → terminal réellement plein écran
|
||||||
|
(la navigation reste accessible via le lien « ← retour » du header de la vue). -->
|
||||||
|
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
||||||
@@ -10,7 +12,7 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<MobileTabBar />
|
<MobileTabBar v-if="!fullbleed" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export default {
|
|||||||
live: 'live',
|
live: 'live',
|
||||||
resumable: 'resumable',
|
resumable: 'resumable',
|
||||||
sourceDiscovered: 'discovered',
|
sourceDiscovered: 'discovered',
|
||||||
|
hide: 'Hide',
|
||||||
|
unhide: 'Unhide',
|
||||||
|
hiddenBadge: 'hidden',
|
||||||
|
hideDiscovered: 'Hide external history',
|
||||||
|
showHidden: 'Show hidden',
|
||||||
|
hideHidden: 'Hide hidden',
|
||||||
clients: 'no client | 1 client | {n} clients',
|
clients: 'no client | 1 client | {n} clients',
|
||||||
exitCode: 'exit code {code}',
|
exitCode: 'exit code {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -168,6 +174,8 @@ export default {
|
|||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
exitFullscreen: 'Exit fullscreen',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Enable notifications',
|
enable: 'Enable notifications',
|
||||||
@@ -298,6 +306,9 @@ export default {
|
|||||||
sessionKilled: 'Session killed',
|
sessionKilled: 'Session killed',
|
||||||
sessionResumed: 'Session resumed',
|
sessionResumed: 'Session resumed',
|
||||||
sessionForked: 'Session forked',
|
sessionForked: 'Session forked',
|
||||||
|
sessionHidden: 'Session hidden',
|
||||||
|
sessionUnhidden: 'Session shown',
|
||||||
|
sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden',
|
||||||
repoAdded: 'Repository added',
|
repoAdded: 'Repository added',
|
||||||
repoRemoved: 'Repository removed',
|
repoRemoved: 'Repository removed',
|
||||||
worktreeCreated: 'Worktree created',
|
worktreeCreated: 'Worktree created',
|
||||||
@@ -306,6 +317,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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ const fr: typeof en = {
|
|||||||
live: 'en cours',
|
live: 'en cours',
|
||||||
resumable: 'reprenable',
|
resumable: 'reprenable',
|
||||||
sourceDiscovered: 'découverte',
|
sourceDiscovered: 'découverte',
|
||||||
|
hide: 'Masquer',
|
||||||
|
unhide: 'Réafficher',
|
||||||
|
hiddenBadge: 'masquée',
|
||||||
|
hideDiscovered: 'Masquer l’historique externe',
|
||||||
|
showHidden: 'Afficher les masquées',
|
||||||
|
hideHidden: 'Masquer les masquées',
|
||||||
clients: 'aucun client | 1 client | {n} clients',
|
clients: 'aucun client | 1 client | {n} clients',
|
||||||
exitCode: 'code de sortie {code}',
|
exitCode: 'code de sortie {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -171,6 +177,8 @@ const fr: typeof en = {
|
|||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
notAttachable:
|
notAttachable:
|
||||||
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
||||||
|
fullscreen: 'Plein écran',
|
||||||
|
exitFullscreen: 'Quitter le plein écran',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Activer les notifications',
|
enable: 'Activer les notifications',
|
||||||
@@ -301,6 +309,9 @@ const fr: typeof en = {
|
|||||||
sessionKilled: 'Session tuée',
|
sessionKilled: 'Session tuée',
|
||||||
sessionResumed: 'Session reprise',
|
sessionResumed: 'Session reprise',
|
||||||
sessionForked: 'Session dupliquée',
|
sessionForked: 'Session dupliquée',
|
||||||
|
sessionHidden: 'Session masquée',
|
||||||
|
sessionUnhidden: 'Session réaffichée',
|
||||||
|
sessionsHidden: 'aucune session masquée | 1 session masquée | {n} sessions masquées',
|
||||||
repoAdded: 'Dépôt ajouté',
|
repoAdded: 'Dépôt ajouté',
|
||||||
repoRemoved: 'Dépôt retiré',
|
repoRemoved: 'Dépôt retiré',
|
||||||
worktreeCreated: 'Worktree créé',
|
worktreeCreated: 'Worktree créé',
|
||||||
@@ -309,6 +320,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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type { CreateSessionRequest, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
const sessions = ref<SessionSummary[]>([]);
|
const sessions = ref<SessionSummary[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadError = ref<string | null>(null);
|
const loadError = ref<string | null>(null);
|
||||||
|
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
||||||
|
const showHidden = ref(false);
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
||||||
@@ -15,7 +17,17 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remove(id: string): void {
|
||||||
|
const idx = sessions.value.findIndex((s) => s.id === id);
|
||||||
|
if (idx >= 0) sessions.value.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
function upsert(session: SessionSummary): void {
|
function upsert(session: SessionSummary): void {
|
||||||
|
// session masquée et mode « afficher les masquées » inactif : on la retire de la liste.
|
||||||
|
if (session.hidden && !showHidden.value) {
|
||||||
|
remove(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
||||||
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
||||||
else sessions.value.push(session);
|
else sessions.value.push(session);
|
||||||
@@ -43,7 +55,8 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = null;
|
loadError.value = null;
|
||||||
try {
|
try {
|
||||||
const res = await api.get<SessionsListResponse>('/api/v1/sessions');
|
const url = showHidden.value ? '/api/v1/sessions?includeHidden=true' : '/api/v1/sessions';
|
||||||
|
const res = await api.get<SessionsListResponse>(url);
|
||||||
sessions.value = sorted(res.sessions);
|
sessions.value = sorted(res.sessions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadError.value = err instanceof Error ? err.message : String(err);
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
@@ -52,6 +65,31 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setShowHidden(value: boolean): Promise<void> {
|
||||||
|
showHidden.value = value;
|
||||||
|
await fetchSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masque une session découverte (retrait optimiste si on n'affiche pas les masquées).
|
||||||
|
async function hideSession(id: string): Promise<void> {
|
||||||
|
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
||||||
|
if (!showHidden.value) remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ré-affiche une session masquée.
|
||||||
|
async function unhideSession(id: string): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masque tout l'historique de sessions externes visible. Renvoie le nombre masqué.
|
||||||
|
async function hideDiscovered(): Promise<number> {
|
||||||
|
const res = await api.post<HideDiscoveredResponse>('/api/v1/sessions/hide-discovered');
|
||||||
|
if (!showHidden.value) {
|
||||||
|
sessions.value = sessions.value.filter((s) => !(s.source === 'discovered' && !s.hidden));
|
||||||
|
}
|
||||||
|
return res.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
function startRealtime(): void {
|
function startRealtime(): void {
|
||||||
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
||||||
}
|
}
|
||||||
@@ -90,12 +128,17 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
sessions,
|
sessions,
|
||||||
loading,
|
loading,
|
||||||
loadError,
|
loadError,
|
||||||
|
showHidden,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
|
setShowHidden,
|
||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
createSession,
|
createSession,
|
||||||
killSession,
|
killSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
forkSession,
|
forkSession,
|
||||||
|
hideSession,
|
||||||
|
unhideSession,
|
||||||
|
hideDiscovered,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-col">
|
<div ref="root" class="flex min-h-0 flex-1 flex-col bg-[#09090b]">
|
||||||
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
||||||
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
||||||
← {{ t('terminal.back') }}
|
← {{ t('terminal.back') }}
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||||
{{ session?.cwd ?? sessionId }}
|
{{ session?.cwd ?? sessionId }}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto shrink-0 rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
:title="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
|
||||||
|
:aria-label="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
||||||
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||||
@@ -19,9 +28,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Maximize, Minimize } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import TerminalView from '../components/TerminalView.vue';
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
@@ -36,8 +46,25 @@ const session = computed(() => store.sessions.find((s) => s.id === sessionId.val
|
|||||||
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
|
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
|
||||||
const attachable = computed(() => session.value?.attachable ?? true);
|
const attachable = computed(() => session.value?.attachable ?? true);
|
||||||
|
|
||||||
|
// Vrai plein écran (API Fullscreen) — surtout utile sur mobile pour récupérer la barre d'URL.
|
||||||
|
// On synchronise `isFullscreen` sur l'événement natif : l'utilisateur peut sortir via Échap.
|
||||||
|
const root = useTemplateRef<HTMLDivElement>('root');
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
function syncFullscreen(): void {
|
||||||
|
isFullscreen.value = document.fullscreenElement === root.value;
|
||||||
|
}
|
||||||
|
function toggleFullscreen(): void {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||||
|
else void root.value?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
|
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
|
||||||
if (store.sessions.length === 0) void store.fetchSessions();
|
if (store.sessions.length === 0) void store.fetchSessions();
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', syncFullscreen);
|
||||||
|
if (document.fullscreenElement === root.value) void document.exitFullscreen().catch(() => {});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader :title="t('nav.sessions')">
|
<PageHeader :title="t('nav.sessions')">
|
||||||
|
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
||||||
|
{{ t('sessions.hideDiscovered') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
|
||||||
|
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
|
||||||
|
</BaseButton>
|
||||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
||||||
{{ t('common.refresh') }}
|
{{ t('common.refresh') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@@ -43,6 +49,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||||
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||||
|
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +80,9 @@
|
|||||||
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
|
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
|
||||||
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
|
||||||
|
<BaseButton v-if="s.hidden" variant="ghost" size="sm" :icon="Eye" :loading="hidingId === s.id" @click="onUnhide(s.id)">{{ t('sessions.unhide') }}</BaseButton>
|
||||||
|
<BaseButton v-else-if="s.source === 'discovered'" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingId === s.id" @click="onHide(s.id)">{{ t('sessions.hide') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -88,11 +98,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
@@ -130,6 +140,10 @@ const creating = ref(false);
|
|||||||
const killArmedId = ref<string | null>(null);
|
const killArmedId = ref<string | null>(null);
|
||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
const actingId = ref<string | null>(null);
|
const actingId = ref<string | null>(null);
|
||||||
|
const hidingId = ref<string | null>(null);
|
||||||
|
const hidingAll = ref(false);
|
||||||
|
// au moins une session externe visible → propose le masquage de masse
|
||||||
|
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
|
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
|
||||||
@@ -197,4 +211,48 @@ async function onConfirmKill(id: string): Promise<void> {
|
|||||||
killArmedId.value = null;
|
killArmedId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onHide(id: string): Promise<void> {
|
||||||
|
hidingId.value = id;
|
||||||
|
try {
|
||||||
|
await store.hideSession(id);
|
||||||
|
toasts.success(t('toast.sessionHidden'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnhide(id: string): Promise<void> {
|
||||||
|
hidingId.value = id;
|
||||||
|
try {
|
||||||
|
await store.unhideSession(id);
|
||||||
|
toasts.success(t('toast.sessionUnhidden'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onHideDiscovered(): Promise<void> {
|
||||||
|
hidingAll.value = true;
|
||||||
|
try {
|
||||||
|
const n = await store.hideDiscovered();
|
||||||
|
toasts.success(t('toast.sessionsHidden', n));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleHidden(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await store.setShowHidden(!store.showHidden);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ const en: HelpSection[] = [
|
|||||||
title: 'Discovered sessions',
|
title: 'Discovered sessions',
|
||||||
body: 'Sessions you start in your own terminal are detected automatically and tagged as discovered. Observe them read-only, or resume/fork them once stopped.',
|
body: 'Sessions you start in your own terminal are detected automatically and tagged as discovered. Observe them read-only, or resume/fork them once stopped.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Hide noisy history',
|
||||||
|
body: 'Old sessions started outside Arboretum can pile up. Hide one from its row, or use “Hide external history” to clear them all in one click. Hidden sessions stay resumable and survive a re-scan — toggle “Show hidden” to bring them back.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fullscreen terminal',
|
||||||
|
body: 'The web terminal fills the whole screen, with the prompt pinned to the bottom and full scrollback above. Tap the fullscreen button in the header to go edge-to-edge — handy on phones.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Resume & fork',
|
title: 'Resume & fork',
|
||||||
body: 'Resume restarts a stopped session in its original working directory, with full scrollback replayed. Fork creates a fresh independent copy with the same command and cwd.',
|
body: 'Resume restarts a stopped session in its original working directory, with full scrollback replayed. Fork creates a fresh independent copy with the same command and cwd.',
|
||||||
@@ -122,7 +130,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.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -238,6 +246,14 @@ const fr: HelpSection[] = [
|
|||||||
title: 'Sessions découvertes',
|
title: 'Sessions découvertes',
|
||||||
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découvertes ». Observez-les en lecture seule, ou reprenez/forkez-les une fois arrêtées.',
|
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découvertes ». Observez-les en lecture seule, ou reprenez/forkez-les une fois arrêtées.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Masquer l’historique',
|
||||||
|
body: 'Les anciennes sessions lancées hors d’Arboretum peuvent s’accumuler. Masquez-en une depuis sa ligne, ou utilisez « Masquer l’historique externe » pour toutes les retirer en un clic. Les sessions masquées restent reprenables et survivent à un nouveau scan — activez « Afficher les masquées » pour les retrouver.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Terminal plein écran',
|
||||||
|
body: 'Le terminal web occupe tout l’écran, avec l’invite ancrée en bas et tout l’historique défilable au-dessus. Touchez le bouton plein écran dans l’en-tête pour passer bord à bord — pratique sur téléphone.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Reprendre & forker',
|
title: 'Reprendre & forker',
|
||||||
body: 'Reprendre relance une session arrêtée dans son répertoire d’origine, avec tout l’historique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',
|
body: 'Reprendre relance une session arrêtée dans son répertoire d’origine, avec tout l’historique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',
|
||||||
@@ -290,7 +306,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 s’ouvre directement sur son terminal.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user