2 Commits

Author SHA1 Message Date
e84c6a7f6d release: @johanleroy/git-arboretum 1.7.0
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m0s
CI / Build & test (Node 24) (push) Successful in 9m53s
Deploy site (production) / build-and-deploy (push) Successful in 16s
Release / Publish to Gitea npm registry (push) Successful in 9m46s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m50s
2026-06-22 16:03:11 +02:00
dacff2b98f feat(sessions): masquer les sessions externes + terminal plein écran
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.
2026-06-22 16:02:27 +02:00
23 changed files with 361 additions and 35 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "1.6.1", "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",

View File

@@ -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);

View File

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

View File

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

View File

@@ -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('/')) {
@@ -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) => { 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)) {

View File

@@ -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 dun 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',

View File

@@ -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[] = [];

View File

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

View File

@@ -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).

View File

@@ -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) ----

View File

@@ -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:

View File

@@ -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:

View File

@@ -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>

View File

@@ -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();
}); });

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 lhistorique 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 dArboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.', 'Cette session tourne hors dArboretum — 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éé',

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.',
@@ -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 lhistorique',
body: 'Les anciennes sessions lancées hors dArboretum peuvent saccumuler. Masquez-en une depuis sa ligne, ou utilisez « Masquer lhistorique 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 linvite ancrée en bas et tout lhistorique défilable au-dessus. Touchez le bouton plein écran dans len-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 dorigine, avec tout lhistorique 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 dorigine, avec tout lhistorique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',