From dacff2b98f7d9ee172bc168a76848c4ed79254b5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 22 Jun 2026 16:02:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20masquer=20les=20sessions=20ex?= =?UTF-8?q?ternes=20+=20terminal=20plein=20=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masquage persistant des sessions Claude découvertes (lancées hors Arboretum) qui polluent la liste — calqué sur repos.hidden : - table hidden_sessions (#9) clé claudeSessionId ; helpers DB - DiscoveryService pose le champ additif SessionSummary.hidden (resume/fork restent possibles, survit à un re-scan) - routes POST/DELETE /sessions/:id/hide, POST /sessions/hide-discovered (masse, un clic), GET /sessions?includeHidden=true - UI : bouton Masquer par ligne, « Masquer l'historique externe », toggle « Afficher les masquées », badge masquée ; store + i18n EN/FR + audit Terminal plein écran (corrige le sizing) : - SessionView root s'étire (flex-1) → xterm calcule les bonnes dimensions - refit robuste (requestAnimationFrame + après resync), h-full sur le wrapper - bouton plein écran (API Fullscreen), tabbar mobile masquée en fullbleed DialogPrompt : options en liste verticale pleine largeur (plus de troncature ni de chevauchement sur les libellés longs / multi-lignes). Doc : centre d'aide (sessions), README EN/FR, site (showcase), CLAUDE.md. Tests : discovery (hidden) + route hide/unhide/hide-discovered. --- README.fr.md | 4 +- README.md | 4 +- packages/server/src/app.ts | 3 +- packages/server/src/core/discovery-service.ts | 8 +++ packages/server/src/db/index.ts | 33 ++++++++++ packages/server/src/routes/sessions.ts | 62 +++++++++++++++++-- packages/server/test/app.e2e.test.ts | 14 +++++ .../server/test/discovery-service.test.ts | 27 +++++++- packages/server/test/worktree-manager.test.ts | 2 +- packages/shared/src/api.ts | 5 ++ packages/shared/src/protocol.ts | 3 + packages/site/src/i18n/en.ts | 4 +- packages/site/src/i18n/fr.ts | 4 +- packages/web/src/components/DialogPrompt.vue | 11 ++-- packages/web/src/components/TerminalView.vue | 26 ++++++-- .../web/src/components/layout/AppShell.vue | 6 +- packages/web/src/i18n/en.ts | 11 ++++ packages/web/src/i18n/fr.ts | 11 ++++ packages/web/src/stores/sessions.ts | 47 +++++++++++++- packages/web/src/views/SessionView.vue | 31 +++++++++- packages/web/src/views/SessionsListView.vue | 62 ++++++++++++++++++- packages/web/src/views/help-content.ts | 16 +++++ 22 files changed, 360 insertions(+), 34 deletions(-) diff --git a/README.fr.md b/README.fr.md index 6fa5420..a035987 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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). - **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. -- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur. +- **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 ; 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. - **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. diff --git a/README.md b/README.md index 002cec5..ec7374f 100644 --- a/README.md +++ b/README.md @@ -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). - **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. -- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects. +- **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; 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. - **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. diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 8fa292e..f8d18ee 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -84,6 +84,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund const push = new PushService(db, config.vapidContact, undefined, box); const manager = new PtyManager(db, config.claudeSessionsDir, push); const discovery = new DiscoveryService({ + db, ptyManager: manager, projectsDir: config.claudeProjectsDir, sessionsDir: config.claudeSessionsDir, @@ -168,7 +169,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund }); registerAuthRoutes(app, auth, limiter, serverVersion, db); - registerSessionRoutes(app, manager, discovery); + registerSessionRoutes(app, manager, discovery, db); registerRepoRoutes(app, worktrees, db); registerGroupRoutes(app, groups, db, worktrees, manager); registerWorktreeRoutes(app, worktrees); diff --git a/packages/server/src/core/discovery-service.ts b/packages/server/src/core/discovery-service.ts index 32164e3..97d9492 100644 --- a/packages/server/src/core/discovery-service.ts +++ b/packages/server/src/core/discovery-service.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import type { SessionSummary } from '@arboretum/shared'; import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js'; import { readRegistry, type RegistryEntry } from './session-registry.js'; +import { listHiddenSessionIds, type Db } from '../db/index.js'; import type { PtyManager } from './pty-manager.js'; const DEFAULT_REFRESH_MS = 10_000; @@ -17,6 +18,7 @@ export interface DiscoveryServiceEvents { } export interface DiscoveryOptions { + db: Db; ptyManager: PtyManager; projectsDir?: string; sessionsDir?: string; @@ -24,6 +26,7 @@ export interface DiscoveryOptions { } export class DiscoveryService extends EventEmitter { + private readonly db: Db; private readonly projectsDir: string; private readonly sessionsDir: string; private readonly ptyManager: PtyManager; @@ -35,6 +38,7 @@ export class DiscoveryService extends EventEmitter { constructor(opts: DiscoveryOptions) { super(); + this.db = opts.db; this.ptyManager = opts.ptyManager; this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects'); this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions'); @@ -81,6 +85,9 @@ export class DiscoveryService extends EventEmitter { if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r); } 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). const latest = new Map(); @@ -113,6 +120,7 @@ export class DiscoveryService extends EventEmitter { resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption) attachable: false, // Arboretum ne tient pas le PTY d'une session externe registryStatus: r?.status ?? null, + hidden: hiddenIds.has(d.claudeSessionId), }); } diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 656ceec..7bf6b30 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -129,6 +129,19 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [ 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; @@ -185,3 +198,23 @@ export function getSetting(db: Db, key: string): string | null { 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); } + +// ---- Sessions masquées (par claudeSessionId) ---- + +/** Ensemble des claudeSessionId masqués par l'utilisateur. */ +export function listHiddenSessionIds(db: Db): Set { + 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); +} diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 7615fd7..7da6b64 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -1,13 +1,24 @@ 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 { 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 { - app.get('/api/v1/sessions', async (): Promise => { - return { sessions: mergeSessions(manager.list(), discovery.list()) }; +export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService, db: Db): void { + app.get('/api/v1/sessions', async (req): Promise => { + 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) => { const body = req.body as Partial | null; if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) { @@ -81,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) => { const { id } = req.params as { id: string }; if (!manager.kill(id)) { diff --git a/packages/server/test/app.e2e.test.ts b/packages/server/test/app.e2e.test.ts index 89f6d10..21b3f15 100644 --- a/packages/server/test/app.e2e.test.ts +++ b/packages/server/test/app.e2e.test.ts @@ -148,6 +148,20 @@ describe('app e2e — auth, origin et 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 () => { const res = await t.bundle.app.inject({ method: 'GET', diff --git a/packages/server/test/discovery-service.test.ts b/packages/server/test/discovery-service.test.ts index 0580dcf..87d767f 100644 --- a/packages/server/test/discovery-service.test.ts +++ b/packages/server/test/discovery-service.test.ts @@ -7,7 +7,7 @@ import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.j import { munge } from '../src/core/jsonl-discovery.js'; import { readProcStart } from '../src/core/session-registry.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 { const dir = join(projectsDir, munge(cwd)); @@ -30,7 +30,7 @@ describe('DiscoveryService', () => { sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-')); db = openDb(':memory:'); manager = new PtyManager(db, sessionsDir); - svc = new DiscoveryService({ ptyManager: manager, projectsDir, sessionsDir }); + svc = new DiscoveryService({ db, ptyManager: manager, projectsDir, sessionsDir }); }); afterEach(() => { svc.stop(); @@ -71,6 +71,29 @@ describe('DiscoveryService', () => { 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 () => { writeJsonl(projectsDir, '/home/u/x', 'sid-x'); const seen: SessionSummary[] = []; diff --git a/packages/server/test/worktree-manager.test.ts b/packages/server/test/worktree-manager.test.ts index c717952..33e0d9d 100644 --- a/packages/server/test/worktree-manager.test.ts +++ b/packages/server/test/worktree-manager.test.ts @@ -69,7 +69,7 @@ describe('WorktreeManager', () => { claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-')); dirs.push(claudeHome); 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); }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index d899eda..9c623bd 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -53,6 +53,11 @@ export interface SessionsListResponse { export interface SessionResponse { 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 // sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1). diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index e02639e..a3a5820 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -118,6 +118,9 @@ export interface SessionSummary { addedDirs?: string[]; /** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */ 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) ---- diff --git a/packages/site/src/i18n/en.ts b/packages/site/src/i18n/en.ts index 0d45509..4ab1857 100644 --- a/packages/site/src/i18n/en.ts +++ b/packages/site/src/i18n/en.ts @@ -35,11 +35,11 @@ export default { scAKicker: 'Worktree-first dashboard', scATitle: 'See what needs you — before anything stalls', 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', scBTitle: "Every agent's output, streaming live", 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', scCTitle: 'Unblock an agent from the kitchen', scCBody: diff --git a/packages/site/src/i18n/fr.ts b/packages/site/src/i18n/fr.ts index f566336..b36fbd9 100644 --- a/packages/site/src/i18n/fr.ts +++ b/packages/site/src/i18n/fr.ts @@ -35,11 +35,11 @@ export default { scAKicker: 'Dashboard worktree-first', scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque', 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', scBTitle: 'La sortie de chaque agent, en direct', 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', scCTitle: 'Débloquez un agent depuis la cuisine', scCBody: diff --git a/packages/web/src/components/DialogPrompt.vue b/packages/web/src/components/DialogPrompt.vue index e93f1cf..837c609 100644 --- a/packages/web/src/components/DialogPrompt.vue +++ b/packages/web/src/components/DialogPrompt.vue @@ -4,18 +4,21 @@ {{ t(`sessions.dialog.kind.${dialog.kind}`) }} {{ dialog.waitingFor ?? t('sessions.dialog.waiting') }} -
+ +
-
diff --git a/packages/web/src/components/TerminalView.vue b/packages/web/src/components/TerminalView.vue index 89e1ad3..9bc18f6 100644 --- a/packages/web/src/components/TerminalView.vue +++ b/packages/web/src/components/TerminalView.vue @@ -1,5 +1,5 @@ diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 130c2d9..2059443 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -41,6 +41,12 @@ export default { live: 'live', resumable: 'resumable', 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', exitCode: 'exit code {code}', statusLabel: { @@ -168,6 +174,8 @@ export default { sessionEnded: 'Session ended', back: 'Sessions', notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.', + fullscreen: 'Fullscreen', + exitFullscreen: 'Exit fullscreen', }, push: { enable: 'Enable notifications', @@ -298,6 +306,9 @@ export default { sessionKilled: 'Session killed', sessionResumed: 'Session resumed', sessionForked: 'Session forked', + sessionHidden: 'Session hidden', + sessionUnhidden: 'Session shown', + sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden', repoAdded: 'Repository added', repoRemoved: 'Repository removed', worktreeCreated: 'Worktree created', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 769086d..6ec919f 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -43,6 +43,12 @@ const fr: typeof en = { live: 'en cours', resumable: 'reprenable', 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', exitCode: 'code de sortie {code}', statusLabel: { @@ -171,6 +177,8 @@ const fr: typeof en = { back: 'Sessions', notAttachable: '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: { enable: 'Activer les notifications', @@ -301,6 +309,9 @@ const fr: typeof en = { sessionKilled: 'Session tuée', sessionResumed: 'Session reprise', 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é', repoRemoved: 'Dépôt retiré', worktreeCreated: 'Worktree créé', diff --git a/packages/web/src/stores/sessions.ts b/packages/web/src/stores/sessions.ts index 6d8e9e7..0d43ee1 100644 --- a/packages/web/src/stores/sessions.ts +++ b/packages/web/src/stores/sessions.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; 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 { wsClient, type SessionEvent } from '../lib/ws-client'; @@ -8,6 +8,8 @@ export const useSessionsStore = defineStore('sessions', () => { const sessions = ref([]); const loading = ref(false); const loadError = ref(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; // 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)); } + 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 { + // 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); if (idx >= 0) sessions.value.splice(idx, 1, session); else sessions.value.push(session); @@ -43,7 +55,8 @@ export const useSessionsStore = defineStore('sessions', () => { loading.value = true; loadError.value = null; try { - const res = await api.get('/api/v1/sessions'); + const url = showHidden.value ? '/api/v1/sessions?includeHidden=true' : '/api/v1/sessions'; + const res = await api.get(url); sessions.value = sorted(res.sessions); } catch (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 { + 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 { + 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 { + 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 { + const res = await api.post('/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 { unsubscribe ??= wsClient.subscribeSessions(onEvent); } @@ -90,12 +128,17 @@ export const useSessionsStore = defineStore('sessions', () => { sessions, loading, loadError, + showHidden, fetchSessions, + setShowHidden, startRealtime, stopRealtime, createSession, killSession, resumeSession, forkSession, + hideSession, + unhideSession, + hideDiscovered, }; }); diff --git a/packages/web/src/views/SessionView.vue b/packages/web/src/views/SessionView.vue index 8df9194..68a564a 100644 --- a/packages/web/src/views/SessionView.vue +++ b/packages/web/src/views/SessionView.vue @@ -1,5 +1,5 @@ diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue index 0437cdf..0c1ded9 100644 --- a/packages/web/src/views/SessionsListView.vue +++ b/packages/web/src/views/SessionsListView.vue @@ -1,5 +1,11 @@ + + {{ t('sessions.unhide') }} + {{ t('sessions.hide') }}
@@ -88,11 +98,11 @@ diff --git a/packages/web/src/views/help-content.ts b/packages/web/src/views/help-content.ts index 974437a..5c89142 100644 --- a/packages/web/src/views/help-content.ts +++ b/packages/web/src/views/help-content.ts @@ -70,6 +70,14 @@ const en: HelpSection[] = [ 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.', }, + { + 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', 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.', @@ -238,6 +246,14 @@ const fr: HelpSection[] = [ 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.', }, + { + 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', 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.',