fix: recherche worktrees, sessions externes masquées, rendu/historique terminal & plein écran grille

- Recherche : le watcher débouncé du singleton useWorktreeView est créé dans un effectScope
  détaché → ne gèle plus après une navigation ; recherche aussi par label de dépôt ; accès à
  la palette depuis le MoreSheet mobile (sidebar/⌘K indisponibles sur mobile).
- Sessions : sessionsForCwd exclut désormais toujours les sessions masquées ; les sessions
  externes (discovered) sont masquées par défaut côté UI, avec puce de révélation par carte et
  case globale dans le dashboard.
- Terminal : ACK découplé du callback de rendu (armé à la réception + flush au retour de
  visibilité) → plus de pause PTY quand l'onglet passe en arrière-plan ; repaint forcé sur
  perte de contexte WebGL / resize / retour en avant-plan. Historique élargi : REPLAY_TAIL_BYTES
  1 Mo, RING_CAPACITY 4 Mo, scrollback xterm 20k.
- Grille de groupe : bouton « plein écran » sur chaque cellule ouvrant /sessions/:id.
This commit is contained in:
2026-06-23 17:42:49 +02:00
parent 5ebe3a0f37
commit 663ae7ace1
15 changed files with 192 additions and 30 deletions

View File

@@ -12,7 +12,9 @@ import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js'; import type { PushService } from './push-service.js';
import type { Db } from '../db/index.js'; import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024; // 4 Mo : conserve assez d'historique pour que le replay (REPLAY_TAIL_BYTES = 1 Mo) reste largement
// dans le ring et qu'on puisse remonter une conversation Claude après ré-attache.
const RING_CAPACITY = 4 * 1024 * 1024;
const KILL_GRACE_MS = 5000; const KILL_GRACE_MS = 5000;
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */ /** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
const NOTIFY_DEBOUNCE_MS = 1500; const NOTIFY_DEBOUNCE_MS = 1500;

View File

@@ -250,10 +250,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
// ---- worktrees ---- // ---- worktrees ----
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */ /**
private sessionsForCwd(path: string): SessionSummary[] { * Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
* Les sessions explicitement masquées (`hidden`) sont exclues — cohérent avec `/api/v1/sessions`
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
*/
private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] {
const rp = resolve(path); const rp = resolve(path);
return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp); return mergeSessions(this.ptyManager.list(), this.discovery.list())
.filter((s) => resolve(s.cwd) === rp)
.filter((s) => opts?.includeHidden || !s.hidden);
} }
private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary { private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
@@ -507,7 +515,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo'); if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree'); if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite. // garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
if (!force && this.sessionsForCwd(w.path).some((s) => s.live)) { if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway'); throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
} }
return this.withLock(repoId, async () => { return this.withLock(repoId, async () => {

View File

@@ -243,7 +243,11 @@ describe('PtyManager (pty mocké)', () => {
it('resync à lattach = queue du ring (REPLAY_TAIL_BYTES max)', () => { it('resync à lattach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
const { summary, pty } = spawnBash(); const { summary, pty } = spawnBash();
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024)); // on écrit volontairement PLUS que REPLAY_TAIL_BYTES (mais < RING_CAPACITY) pour vérifier
// le plafonnement de la queue rejouée. Tailles dérivées de la constante → robuste aux bumps.
const chunkSize = 256 * 1024;
const chunkCount = Math.ceil(REPLAY_TAIL_BYTES / chunkSize) + 2;
const chunks = Array.from({ length: chunkCount }, (_, i) => String.fromCharCode(65 + (i % 26)).repeat(chunkSize));
for (const c of chunks) pty.emitData(c); for (const c of chunks) pty.emitData(c);
const b = makeBinding('interactive'); const b = makeBinding('interactive');

View File

@@ -51,8 +51,12 @@ export const FLOW = {
LAGGING_BYTES: 2 * 1024 * 1024, LAGGING_BYTES: 2 * 1024 * 1024,
} as const; } as const;
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */ /**
export const REPLAY_TAIL_BYTES = 256 * 1024; * Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu).
* 1 Mo (≈ 1015k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ;
* reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY.
*/
export const REPLAY_TAIL_BYTES = 1024 * 1024;
// ---- États de session (sous-ensemble P1 ; étendu en P3) ---- // ---- États de session (sous-ensemble P1 ; étendu en P3) ----
export type SessionRuntimeStatus = export type SessionRuntimeStatus =

View File

@@ -12,6 +12,14 @@
{{ t('groups.spanBadge', spannedCount) }} {{ t('groups.spanBadge', spannedCount) }}
</span> </span>
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span> <span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
<button
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
:aria-label="t('terminal.openFullscreen')"
:title="t('terminal.openFullscreen')"
@click="emit('open-fullscreen')"
>
<Expand class="h-3.5 w-3.5" />
</button>
<button <button
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200" class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')" :aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
@@ -31,7 +39,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Maximize2, Minimize2 } from '@lucide/vue'; import { Expand, Maximize2, Minimize2 } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue'; import SessionStateBadge from './SessionStateBadge.vue';
@@ -39,7 +47,7 @@ import DialogPrompt from './DialogPrompt.vue';
import TerminalView from './TerminalView.vue'; import TerminalView from './TerminalView.vue';
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>(); const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
const emit = defineEmits<{ 'toggle-maximize': [] }>(); const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -11,7 +11,13 @@
Plancher min-h-80 pour les fenêtres courtes. --> Plancher min-h-80 pour les fenêtres courtes. -->
<!-- une cellule maximisée plein cadre ; sinon la grille responsive --> <!-- une cellule maximisée plein cadre ; sinon la grille responsive -->
<div v-if="activeMax" class="grid grid-cols-1"> <div v-if="activeMax" class="grid grid-cols-1">
<TerminalCell :session="activeMax" maximized class="h-[calc(100dvh-8rem)] min-h-80" @toggle-maximize="toggleMax(activeMax.id)" /> <TerminalCell
:session="activeMax"
maximized
class="h-[calc(100dvh-8rem)] min-h-80"
@toggle-maximize="toggleMax(activeMax.id)"
@open-fullscreen="openFullscreen(activeMax.id)"
/>
</div> </div>
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3"> <div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
<TerminalCell <TerminalCell
@@ -20,6 +26,7 @@
:session="s" :session="s"
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]" class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
@toggle-maximize="toggleMax(s.id)" @toggle-maximize="toggleMax(s.id)"
@open-fullscreen="openFullscreen(s.id)"
/> />
</div> </div>
</template> </template>
@@ -29,15 +36,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import TerminalCell from './TerminalCell.vue'; import TerminalCell from './TerminalCell.vue';
const props = defineProps<{ sessions: SessionSummary[] }>(); const props = defineProps<{ sessions: SessionSummary[] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const MAX_CELLS = 6; const MAX_CELLS = 6;
const shown = computed(() => props.sessions.slice(0, MAX_CELLS)); const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
function openFullscreen(id: string): void {
void router.push({ name: 'session', params: { id } });
}
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session // Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
// maximisée disparaît (terminée), on retombe automatiquement sur la grille. // maximisée disparaît (terminée), on retombe automatiquement sur la grille.
const maximizedId = ref<string | null>(null); const maximizedId = ref<string | null>(null);

View File

@@ -38,6 +38,7 @@ const ended = ref(false);
let term: Terminal | null = null; let term: Terminal | null = null;
let attachment: Attachment | null = null; let attachment: Attachment | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let onVisible: (() => void) | null = null;
let disposed = false; let disposed = false;
onMounted(async () => { onMounted(async () => {
@@ -47,7 +48,7 @@ onMounted(async () => {
cursorBlink: true, cursorBlink: true,
fontSize: 13, fontSize: 13,
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace", fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
scrollback: 10_000, scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
theme: { theme: {
background: '#09090b', background: '#09090b',
foreground: '#d4d4d8', foreground: '#d4d4d8',
@@ -58,18 +59,24 @@ onMounted(async () => {
const fit = new FitAddon(); const fit = new FitAddon();
term.loadAddon(fit); term.loadAddon(fit);
term.open(container.value); term.open(container.value);
const activeTerm = term;
try { try {
const { WebglAddon } = await import('@xterm/addon-webgl'); const { WebglAddon } = await import('@xterm/addon-webgl');
const webgl = new WebglAddon(); const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose()); // perte de contexte GPU : on dispose (retour au renderer DOM) PUIS on repeint — sinon l'écran
// reste figé sur le dernier rendu WebGL jusqu'à la prochaine interaction (« texte qui ne s'affiche
// que si ça bouge »).
webgl.onContextLoss(() => {
webgl.dispose();
activeTerm.refresh(0, activeTerm.rows - 1);
});
term.loadAddon(webgl); term.loadAddon(webgl);
} catch { } catch {
// WebGL indisponible : xterm retombe sur le renderer DOM // WebGL indisponible : xterm retombe sur le renderer DOM
} }
fit.fit(); fit.fit();
const activeTerm = term;
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa // 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 // taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
@@ -82,6 +89,9 @@ onMounted(async () => {
/* conteneur sans dimension (démontage en cours) : ignoré */ /* conteneur sans dimension (démontage en cours) : ignoré */
} }
attachment?.resize(activeTerm.cols, activeTerm.rows); attachment?.resize(activeTerm.cols, activeTerm.rows);
// repaint explicite : le renderer WebGL ne repeint pas toujours une zone non « dirty » (retour
// d'arrière-plan, resync) → on force un rafraîchissement pour éviter un écran figé.
activeTerm.refresh(0, activeTerm.rows - 1);
}; };
try { try {
@@ -120,12 +130,19 @@ onMounted(async () => {
activeTerm.onData((data) => attachment?.sendStdin(data)); activeTerm.onData((data) => attachment?.sendStdin(data));
resizeObserver = new ResizeObserver(refit); resizeObserver = new ResizeObserver(refit);
resizeObserver.observe(container.value); resizeObserver.observe(container.value);
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
onVisible = (): void => {
if (document.visibilityState === 'visible') activeTerm.refresh(0, activeTerm.rows - 1);
};
document.addEventListener('visibilitychange', onVisible);
if (props.mode === 'interactive') activeTerm.focus(); if (props.mode === 'interactive') activeTerm.focus();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true; disposed = true;
resizeObserver?.disconnect(); resizeObserver?.disconnect();
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
attachment?.detach(); attachment?.detach();
term?.dispose(); term?.dispose();
}); });

View File

@@ -16,7 +16,7 @@
<div class="mt-1 flex flex-wrap items-center gap-2"> <div class="mt-1 flex flex-wrap items-center gap-2">
<RouterLink <RouterLink
v-for="s in worktree.sessions" v-for="s in visibleSessions"
:key="s.id" :key="s.id"
:to="{ name: 'session', params: { id: s.id } }" :to="{ name: 'session', params: { id: s.id } }"
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800" class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
@@ -24,7 +24,16 @@
<SessionStateBadge :session="liveSession(s)" /> <SessionStateBadge :session="liveSession(s)" />
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span> <span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
</RouterLink> </RouterLink>
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span> <!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
<button
v-if="!store.showExternalSessions && externalCount > 0"
type="button"
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
@click="store.showExternalSessions = true"
>
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
</button>
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale --> <!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button> <button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
@@ -123,6 +132,17 @@ function liveSession(s: SessionSummary): SessionSummary {
return sessions.sessions.find((x) => x.id === s.id) ?? s; return sessions.sessions.find((x) => x.id === s.id) ?? s;
} }
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
const visibleSessions = computed(() =>
store.showExternalSessions
? props.worktree.sessions
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
);
const externalCount = computed(
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
);
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline. // sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
const waitingSessions = computed(() => const waitingSessions = computed(() =>
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog), props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),

View File

@@ -7,6 +7,15 @@
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
> >
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" /> <div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
<!-- accès recherche/palette : sur mobile la sidebar (et K) est indisponible seul point d'entrée -->
<button
type="button"
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="openSearch"
>
<Search :size="16" />
<span>{{ t('common.search') }}</span>
</button>
<nav class="mb-3 flex flex-col gap-1"> <nav class="mb-3 flex flex-col gap-1">
<NavItem <NavItem
v-for="item in secondary" v-for="item in secondary"
@@ -31,12 +40,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav'; import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue'; import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue'; import AppShellFooter from './AppShellFooter.vue';
import BaseButton from '../ui/BaseButton.vue'; import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n(); const { t } = useI18n();
const { secondary, isActive } = useNav(); const { secondary, isActive } = useNav();
const palette = useCommandPalette();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
function openSearch(): void {
emit('close');
palette.show();
}
</script> </script>

View File

@@ -1,4 +1,4 @@
import { computed, ref } from 'vue'; import { computed, effectScope, ref } from 'vue';
import type { WorktreeSummary } from '@arboretum/shared'; import type { WorktreeSummary } from '@arboretum/shared';
import type { ListToolbarControls, SortDir } from './useListControls'; import type { ListToolbarControls, SortDir } from './useListControls';
import { useDebouncedRef } from './useDebouncedRef'; import { useDebouncedRef } from './useDebouncedRef';
@@ -29,7 +29,14 @@ function create(): WorktreeView {
const filterDefList = worktreeFilters(); const filterDefList = worktreeFilters();
const filterDefs = computed(() => filterDefList); const filterDefs = computed(() => filterDefList);
const accessor = (w: WorktreeSummary): string => `${w.branch ?? ''} ${w.path}`.toLowerCase(); // label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt.
const repoLabelById = computed(() => {
const m = new Map<string, string>();
for (const r of useWorktreesStore().repos) m.set(r.id, r.label);
return m;
});
const accessor = (w: WorktreeSummary): string =>
`${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase();
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] { function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
let items = [...list]; let items = [...list];
@@ -98,6 +105,12 @@ function create(): WorktreeView {
} }
export function useWorktreeView(): WorktreeView { export function useWorktreeView(): WorktreeView {
instance ??= create(); // Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés
// au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation),
// gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route.
if (!instance) {
const scope = effectScope(true);
instance = scope.run(() => create())!;
}
return instance; return instance;
} }

View File

@@ -181,6 +181,8 @@ export default {
clean: 'clean', clean: 'clean',
dirty: '{n} changed', dirty: '{n} changed',
noSession: 'no session', noSession: 'no session',
externalSessions: 'external',
showExternalSessions: 'Show external sessions ({n})',
}, },
terminal: { terminal: {
observer: 'observer (read-only)', observer: 'observer (read-only)',
@@ -191,6 +193,7 @@ export default {
exitFullscreen: 'Exit fullscreen', exitFullscreen: 'Exit fullscreen',
maximize: 'Maximize', maximize: 'Maximize',
restore: 'Restore', restore: 'Restore',
openFullscreen: 'Open fullscreen',
}, },
push: { push: {
enable: 'Enable notifications', enable: 'Enable notifications',

View File

@@ -183,6 +183,8 @@ const fr: typeof en = {
clean: 'propre', clean: 'propre',
dirty: '{n} modifié(s)', dirty: '{n} modifié(s)',
noSession: 'aucune session', noSession: 'aucune session',
externalSessions: 'externes',
showExternalSessions: 'Afficher les sessions externes ({n})',
}, },
terminal: { terminal: {
observer: 'observateur (lecture seule)', observer: 'observateur (lecture seule)',
@@ -194,6 +196,7 @@ const fr: typeof en = {
exitFullscreen: 'Quitter le plein écran', exitFullscreen: 'Quitter le plein écran',
maximize: 'Agrandir', maximize: 'Agrandir',
restore: 'Réduire', restore: 'Réduire',
openFullscreen: 'Ouvrir en plein écran',
}, },
push: { push: {
enable: 'Activer les notifications', enable: 'Activer les notifications',

View File

@@ -108,6 +108,7 @@ export class WsClient {
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private ready = false; private ready = false;
private stopped = true; private stopped = true;
private visibilityBound = false;
private backoffMs = BACKOFF_MIN_MS; private backoffMs = BACKOFF_MIN_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */ /** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
@@ -121,6 +122,14 @@ export class WsClient {
connect(): void { connect(): void {
this.stopped = false; this.stopped = false;
// Au retour en avant-plan, le rendu se débloque : on flushe les ACK pour ne pas attendre le
// débounce si le serveur s'était mis en pause pendant que l'onglet/PWA était caché.
if (!this.visibilityBound && typeof document !== 'undefined') {
this.visibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.flushAcks();
});
}
if (this.socket || this.reconnectTimer) return; if (this.socket || this.reconnectTimer) return;
if (this.status.value === 'idle') this.status.value = 'connecting'; if (this.status.value === 'idle') this.status.value = 'connecting';
this.openSocket(); this.openSocket();
@@ -301,6 +310,9 @@ export class WsClient {
att.lastAckBytes = 0; att.lastAckBytes = 0;
att.sink.reset(); att.sink.reset();
if (frame.payload.byteLength > 0) att.sink.write(frame.payload); if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
// filet anti-stall : le replay ne passe PAS de callback de write → on arme quand même l'ACK
// traînant pour que le flux suivant ne puisse pas rester gelé (serveur en pause).
this.scheduleTrailingAck(att, att.epoch);
return; return;
} }
if (frame.type !== BINARY_FRAME.OUTPUT) return; if (frame.type !== BINARY_FRAME.OUTPUT) return;
@@ -315,8 +327,22 @@ export class WsClient {
att.lastAckBytes = att.processedBytes; att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes }); this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
} }
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé, this.scheduleTrailingAck(att, epoch);
// le serveur ne doit jamais rester en pause faute d'ACK suivant. });
// Filet anti-stall, INDÉPENDANT du callback de xterm.write : ce callback est throttlé quand
// l'onglet/PWA passe en arrière-plan (rAF + setTimeout ralentis) → sans cela l'ACK ne partait
// plus, le serveur dépassait HIGH_WATERMARK et mettait le PTY en pause, et la sortie « chargeait
// par à-coups » jusqu'à ce que l'utilisateur bouge. On arme donc l'ACK traînant dès la réception.
this.scheduleTrailingAck(att, epoch);
}
/**
* (Ré)arme l'ACK traînant débouncé : si rien d'autre n'arrive, il envoie le reliquat d'octets
* réellement traités par xterm (`processedBytes`) pour que le serveur sorte toujours de pause.
* Appelé à la fois dans le callback de write ET à la réception d'une frame (le callback peut être
* indéfiniment retardé quand le rendu est gelé). Sémantique inchangée : on n'ACK que du traité.
*/
private scheduleTrailingAck(att: Attachment, epoch: number): void {
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer); if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = setTimeout(() => { att.trailingAckTimer = setTimeout(() => {
att.trailingAckTimer = null; att.trailingAckTimer = null;
@@ -326,7 +352,18 @@ export class WsClient {
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes }); this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
} }
}, 200); }, 200);
}); }
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
private flushAcks(): void {
for (const att of this.attachments) {
if (att.closed || att.channel < 0) continue;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}
} }
private handleServerMessage(msg: ServerMessage): void { private handleServerMessage(msg: ServerMessage): void {

View File

@@ -23,6 +23,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
const worktrees = ref<WorktreeSummary[]>([]); const worktrees = ref<WorktreeSummary[]>([]);
const loading = ref(false); const loading = ref(false);
const loadError = ref<string | null>(null); const loadError = ref<string | null>(null);
// Préférence d'affichage (purement client) : révéler les sessions externes (lancées hors Arboretum)
// dans les fiches worktree. Par défaut masquées pour éviter les listes à rallonge.
const showExternalSessions = ref(false);
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
function upsertRepo(repo: RepoSummary): void { function upsertRepo(repo: RepoSummary): void {
@@ -168,6 +171,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
worktrees, worktrees,
loading, loading,
loadError, loadError,
showExternalSessions,
worktreesForRepo, worktreesForRepo,
fetchAll, fetchAll,
addRepo, addRepo,

View File

@@ -39,6 +39,10 @@
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400"> <label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }} <input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
</label> </label>
<label v-if="externalSessionCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="store.showExternalSessions" type="checkbox" />
{{ t('worktrees.showExternalSessions', { n: externalSessionCount }) }}
</label>
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" /> <EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" /> <RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
<Pagination <Pagination
@@ -82,6 +86,10 @@ const scanning = ref(false);
const showHidden = ref(false); const showHidden = ref(false);
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length); const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
// nombre de sessions externes (lancées hors Arboretum) embarquées dans les worktrees → pilote la case.
const externalSessionCount = computed(
() => store.worktrees.reduce((n, w) => n + w.sessions.filter((s) => s.source !== 'managed').length, 0),
);
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ; // repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo. // puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.