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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user