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.
133 lines
4.0 KiB
Vue
133 lines
4.0 KiB
Vue
<template>
|
|
<div class="relative h-full min-h-0 bg-[#09090b]">
|
|
<div ref="container" class="h-full w-full" />
|
|
<div
|
|
v-if="!controlling && !ended"
|
|
class="pointer-events-none absolute top-2 right-4 rounded bg-zinc-800/90 px-2 py-0.5 text-xs text-amber-300"
|
|
>
|
|
{{ t('terminal.observer') }}
|
|
</div>
|
|
<div
|
|
v-if="ended"
|
|
class="absolute inset-x-0 top-0 bg-red-950/90 px-4 py-2 text-center text-sm text-red-200"
|
|
>
|
|
{{ t('terminal.sessionEnded') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
|
// si controlling), flow control et resync entièrement délégués au ws-client.
|
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { wsClient, type Attachment } from '../lib/ws-client';
|
|
|
|
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
|
mode: 'interactive',
|
|
});
|
|
|
|
const { t } = useI18n();
|
|
const container = useTemplateRef<HTMLDivElement>('container');
|
|
const controlling = ref(true);
|
|
const ended = ref(false);
|
|
|
|
let term: Terminal | null = null;
|
|
let attachment: Attachment | null = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let disposed = false;
|
|
|
|
onMounted(async () => {
|
|
if (!container.value) return;
|
|
|
|
term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
|
scrollback: 10_000,
|
|
theme: {
|
|
background: '#09090b',
|
|
foreground: '#d4d4d8',
|
|
cursor: '#e4e4e7',
|
|
selectionBackground: '#3f3f46',
|
|
},
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(container.value);
|
|
|
|
try {
|
|
const { WebglAddon } = await import('@xterm/addon-webgl');
|
|
const webgl = new WebglAddon();
|
|
webgl.onContextLoss(() => webgl.dispose());
|
|
term.loadAddon(webgl);
|
|
} catch {
|
|
// WebGL indisponible : xterm retombe sur le renderer DOM
|
|
}
|
|
|
|
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,
|
|
mode: props.mode,
|
|
cols: activeTerm.cols,
|
|
rows: activeTerm.rows,
|
|
sink: {
|
|
write: (data, callback) => activeTerm.write(data, callback),
|
|
reset: () => {
|
|
activeTerm.reset();
|
|
requestAnimationFrame(refit);
|
|
},
|
|
onDetached: (reason) => {
|
|
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
|
},
|
|
onControlChanged: (c) => {
|
|
controlling.value = c;
|
|
},
|
|
},
|
|
});
|
|
} catch {
|
|
// session introuvable ou déjà terminée
|
|
ended.value = true;
|
|
controlling.value = false;
|
|
return;
|
|
}
|
|
if (disposed) {
|
|
attachment.detach();
|
|
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(refit);
|
|
resizeObserver.observe(container.value);
|
|
if (props.mode === 'interactive') activeTerm.focus();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
disposed = true;
|
|
resizeObserver?.disconnect();
|
|
attachment?.detach();
|
|
term?.dispose();
|
|
});
|
|
</script>
|