Compare commits
2 Commits
bf9c6ba8f3
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e84c6a7f6d | |||
| dacff2b98f |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
<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>
|
||||
</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
|
||||
v-for="opt in dialog.options"
|
||||
: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' : ''"
|
||||
:disabled="busy"
|
||||
@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 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') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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
|
||||
v-if="!controlling && !ended"
|
||||
@@ -71,6 +71,19 @@ onMounted(async () => {
|
||||
fit.fit();
|
||||
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 {
|
||||
attachment = await wsClient.attach({
|
||||
sessionId: props.sessionId,
|
||||
@@ -79,7 +92,10 @@ onMounted(async () => {
|
||||
rows: activeTerm.rows,
|
||||
sink: {
|
||||
write: (data, callback) => activeTerm.write(data, callback),
|
||||
reset: () => activeTerm.reset(),
|
||||
reset: () => {
|
||||
activeTerm.reset();
|
||||
requestAnimationFrame(refit);
|
||||
},
|
||||
onDetached: (reason) => {
|
||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||
},
|
||||
@@ -99,12 +115,10 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
controlling.value = attachment.controlling;
|
||||
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
||||
|
||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||
});
|
||||
resizeObserver = new ResizeObserver(refit);
|
||||
resizeObserver.observe(container.value);
|
||||
if (props.mode === 'interactive') activeTerm.focus();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||
<AppSidebar />
|
||||
<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 />
|
||||
</main>
|
||||
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
||||
@@ -10,7 +12,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
<MobileTabBar v-if="!fullbleed" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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éé',
|
||||
|
||||
@@ -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<SessionSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
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;
|
||||
|
||||
// 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<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);
|
||||
} 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<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 {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
||||
← {{ t('terminal.back') }}
|
||||
@@ -8,6 +8,15 @@
|
||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||
{{ session?.cwd ?? sessionId }}
|
||||
</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>
|
||||
<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" />
|
||||
@@ -19,9 +28,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Maximize, Minimize } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import TerminalView from '../components/TerminalView.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.
|
||||
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(() => {
|
||||
// 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();
|
||||
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', syncFullscreen);
|
||||
if (document.fullscreenElement === root.value) void document.exitFullscreen().catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<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()">
|
||||
{{ t('common.refresh') }}
|
||||
</BaseButton>
|
||||
@@ -43,6 +49,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<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.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||
</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 size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
||||
</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>
|
||||
</li>
|
||||
@@ -88,11 +98,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
@@ -130,6 +140,10 @@ const creating = ref(false);
|
||||
const killArmedId = ref<string | null>(null);
|
||||
const killing = ref(false);
|
||||
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(() => {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user