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.
This commit is contained in:
2026-06-22 16:02:27 +02:00
parent bf9c6ba8f3
commit dacff2b98f
22 changed files with 360 additions and 34 deletions

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

View File

@@ -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<DiscoveryServiceEvents> {
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<DiscoveryServiceEvents> {
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<DiscoveryServiceEvents> {
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<string, DiscoveredJsonl>();
@@ -113,6 +120,7 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
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),
});
}

View File

@@ -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<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 { 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<SessionsListResponse> => {
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<SessionsListResponse> => {
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<CreateSessionRequest> | 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)) {