diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue
index b7e80a7..0564a34 100644
--- a/packages/web/src/App.vue
+++ b/packages/web/src/App.vue
@@ -15,6 +15,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { wsClient } from './lib/ws-client';
+import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
import WsBanner from './components/layout/WsBanner.vue';
import AppShell from './components/layout/AppShell.vue';
import ToastContainer from './components/ToastContainer.vue';
@@ -23,4 +24,8 @@ import CommandPalette from './components/CommandPalette.vue';
const route = useRoute();
const layout = computed(() => route.meta.layout ?? 'shell');
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
+
+// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté)
+// pour couvrir TOUS les layouts, y compris la vue IDE plein écran sans AppShell.
+useRealtimeBootstrap();
diff --git a/packages/web/src/components/ide/PanelSplitter.vue b/packages/web/src/components/ide/PanelSplitter.vue
new file mode 100644
index 0000000..b6bd4c1
--- /dev/null
+++ b/packages/web/src/components/ide/PanelSplitter.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/packages/web/src/components/layout/AppShell.vue b/packages/web/src/components/layout/AppShell.vue
index 3d6a1e4..75578bd 100644
--- a/packages/web/src/components/layout/AppShell.vue
+++ b/packages/web/src/components/layout/AppShell.vue
@@ -18,40 +18,11 @@
diff --git a/packages/web/src/composables/useRealtimeBootstrap.ts b/packages/web/src/composables/useRealtimeBootstrap.ts
new file mode 100644
index 0000000..0f96680
--- /dev/null
+++ b/packages/web/src/composables/useRealtimeBootstrap.ts
@@ -0,0 +1,53 @@
+import { watch } from 'vue';
+import { useAuthStore } from '../stores/auth';
+import { useSessionsStore } from '../stores/sessions';
+import { useWorktreesStore } from '../stores/worktrees';
+import { useGroupsStore } from '../stores/groups';
+import { useSettingsStore } from '../stores/settings';
+
+/**
+ * Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees,
+ * groupes, réglages). Monté dans App.vue, il est donc actif quel que soit le layout, y compris
+ * la vue IDE plein écran (qui n'a pas d'AppShell). Démarre à l'authentification, s'arrête à la
+ * déconnexion. Les stores gardent leurs abonnements par `??=`, donc l'appel est idempotent.
+ */
+export function useRealtimeBootstrap(): void {
+ const auth = useAuthStore();
+ const sessions = useSessionsStore();
+ const worktrees = useWorktreesStore();
+ const groups = useGroupsStore();
+ const settings = useSettingsStore();
+
+ let started = false;
+
+ function start(): void {
+ if (started) return;
+ started = true;
+ void worktrees.fetchAll();
+ void sessions.fetchSessions();
+ void groups.fetchGroups();
+ void settings.fetch(); // alimente l'icône de nav Gitea et la vue Réglages
+ worktrees.startRealtime();
+ sessions.startRealtime();
+ groups.startRealtime();
+ settings.startRealtime();
+ }
+
+ function stop(): void {
+ if (!started) return;
+ started = false;
+ worktrees.stopRealtime();
+ sessions.stopRealtime();
+ groups.stopRealtime();
+ settings.stopRealtime();
+ }
+
+ watch(
+ () => auth.authenticated,
+ (v) => {
+ if (v) start();
+ else if (v === false) stop();
+ },
+ { immediate: true },
+ );
+}
diff --git a/packages/web/src/composables/useSplitter.ts b/packages/web/src/composables/useSplitter.ts
new file mode 100644
index 0000000..d3b0c35
--- /dev/null
+++ b/packages/web/src/composables/useSplitter.ts
@@ -0,0 +1,56 @@
+import { onBeforeUnmount } from 'vue';
+
+export interface SplitterOptions {
+ axis: 'x' | 'y';
+ min: number;
+ max: number;
+ /** Poignée à droite / en bas du panneau : le sens du drag est inversé. */
+ invert?: boolean;
+ get: () => number;
+ set: (v: number) => void;
+}
+
+/**
+ * Calcule la nouvelle taille (fonction pure, testable) : delta de position depuis le début du
+ * drag, inversion éventuelle, puis clamp entre min et max.
+ */
+export function nextSize(
+ startSize: number,
+ startPos: number,
+ curPos: number,
+ o: { min: number; max: number; invert?: boolean },
+): number {
+ let delta = curPos - startPos;
+ if (o.invert) delta = -delta;
+ return Math.min(o.max, Math.max(o.min, startSize + delta));
+}
+
+/**
+ * Poignée de redimensionnement générique (pointer events : souris, tactile, stylet).
+ * Généralise le resize à la souris de l'ancienne WorkspaceView. Écrit via `opts.set`
+ * (typiquement une ref persistée), ce qui permet de persister largeurs/hauteurs de panneaux.
+ */
+export function useSplitter(opts: SplitterOptions): { onPointerDown: (ev: PointerEvent) => void } {
+ let stop: (() => void) | null = null;
+
+ function onPointerDown(ev: PointerEvent): void {
+ ev.preventDefault();
+ const startPos = opts.axis === 'x' ? ev.clientX : ev.clientY;
+ const startSize = opts.get();
+ const onMove = (e: PointerEvent): void => {
+ const cur = opts.axis === 'x' ? e.clientX : e.clientY;
+ opts.set(nextSize(startSize, startPos, cur, opts));
+ };
+ const onUp = (): void => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ stop = null;
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ stop = onUp;
+ }
+
+ onBeforeUnmount(() => stop?.());
+ return { onPointerDown };
+}
diff --git a/packages/web/src/composables/useWorkspaceLayout.ts b/packages/web/src/composables/useWorkspaceLayout.ts
index 073c0ce..ef55243 100644
--- a/packages/web/src/composables/useWorkspaceLayout.ts
+++ b/packages/web/src/composables/useWorkspaceLayout.ts
@@ -1,28 +1,9 @@
// Disposition de la vue IDE persistée en localStorage : largeurs des colonnes (desktop) et
// panneau actif (mobile). Refs réactives ; chaque changement est réécrit en localStorage.
-import { ref, watch, type Ref } from 'vue';
+import { persistedRef } from '../lib/persisted-ref';
export type MobilePanel = 'files' | 'editor' | 'diff' | 'terminal';
-function persistedRef(key: string, initial: T): Ref {
- let start = initial;
- try {
- const raw = localStorage.getItem(key);
- if (raw != null) start = JSON.parse(raw) as T;
- } catch {
- /* localStorage indisponible / JSON corrompu : on garde le défaut */
- }
- const r = ref(start) as Ref;
- watch(r, (v) => {
- try {
- localStorage.setItem(key, JSON.stringify(v));
- } catch {
- /* quota / mode privé : silencieux */
- }
- });
- return r;
-}
-
export function useWorkspaceLayout() {
// largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe.
const leftWidth = persistedRef('arb.ws.leftWidth', 280);
diff --git a/packages/web/src/lib/persisted-ref.ts b/packages/web/src/lib/persisted-ref.ts
new file mode 100644
index 0000000..14f74ff
--- /dev/null
+++ b/packages/web/src/lib/persisted-ref.ts
@@ -0,0 +1,23 @@
+import { ref, watch, type Ref } from 'vue';
+
+// Ref réactive persistée en localStorage : lue au démarrage (défaut si absente ou corrompue),
+// réécrite à chaque changement. Gardée par try/catch, donc utilisable sans localStorage
+// (tests en environnement node, navigation privée, quota dépassé).
+export function persistedRef(key: string, initial: T): Ref {
+ let start = initial;
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw != null) start = JSON.parse(raw) as T;
+ } catch {
+ /* localStorage indisponible / JSON corrompu : on garde le défaut */
+ }
+ const r = ref(start) as Ref;
+ watch(r, (v) => {
+ try {
+ localStorage.setItem(key, JSON.stringify(v));
+ } catch {
+ /* quota / mode privé : silencieux */
+ }
+ });
+ return r;
+}
diff --git a/packages/web/src/views/WorkspaceView.vue b/packages/web/src/views/WorkspaceView.vue
index 6147213..3163785 100644
--- a/packages/web/src/views/WorkspaceView.vue
+++ b/packages/web/src/views/WorkspaceView.vue
@@ -196,9 +196,8 @@ function startResize(which: 'left' | 'right', ev: MouseEvent): void {
let unwatch: (() => void) | null = null;
onMounted(() => {
- // l'AppShell n'est pas monté en layout 'ide' → on assure données + temps réel ici.
- worktrees.startRealtime();
- sessions.startRealtime();
+ // Le temps réel est possédé globalement par useRealtimeBootstrap (App.vue) ; on ne garde ici
+ // que les garde-fous de données pour une navigation directe vers /workspace.
if (worktrees.worktrees.length === 0) void worktrees.fetchAll();
else void worktrees.refreshRepoWorktrees(repoId);
if (sessions.sessions.length === 0) void sessions.fetchSessions();
diff --git a/packages/web/test/persisted-ref.test.ts b/packages/web/test/persisted-ref.test.ts
new file mode 100644
index 0000000..e069b87
--- /dev/null
+++ b/packages/web/test/persisted-ref.test.ts
@@ -0,0 +1,47 @@
+// persistedRef : lecture initiale depuis localStorage, repli sur défaut, réécriture, robustesse.
+import { describe, it, expect, beforeEach } from 'vitest';
+import { nextTick } from 'vue';
+import { persistedRef } from '../src/lib/persisted-ref';
+
+function fakeStorage() {
+ const m = new Map();
+ return {
+ getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
+ setItem: (k: string, v: string) => void m.set(k, v),
+ removeItem: (k: string) => void m.delete(k),
+ clear: () => m.clear(),
+ };
+}
+
+describe('persistedRef', () => {
+ beforeEach(() => {
+ (globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
+ });
+
+ it('lit la valeur initiale depuis localStorage si présente', () => {
+ localStorage.setItem('k', JSON.stringify(42));
+ expect(persistedRef('k', 0).value).toBe(42);
+ });
+
+ it('retombe sur le défaut si la clé est absente', () => {
+ expect(persistedRef('missing', 7).value).toBe(7);
+ });
+
+ it('retombe sur le défaut si le JSON est corrompu', () => {
+ localStorage.setItem('bad', '{not json');
+ expect(persistedRef('bad', 'def').value).toBe('def');
+ });
+
+ it('réécrit en localStorage au changement', async () => {
+ const r = persistedRef('w', 1);
+ r.value = 99;
+ await nextTick();
+ expect(JSON.parse(localStorage.getItem('w') as string)).toBe(99);
+ });
+
+ it('ne jette pas si localStorage est absent (env node sans DOM)', () => {
+ delete (globalThis as unknown as { localStorage?: unknown }).localStorage;
+ expect(() => persistedRef('x', 5)).not.toThrow();
+ expect(persistedRef('x', 5).value).toBe(5);
+ });
+});
diff --git a/packages/web/test/use-splitter.test.ts b/packages/web/test/use-splitter.test.ts
new file mode 100644
index 0000000..d7bdf11
--- /dev/null
+++ b/packages/web/test/use-splitter.test.ts
@@ -0,0 +1,25 @@
+// nextSize : calcul de la nouvelle taille d'un panneau (delta, inversion, clamp min/max).
+import { describe, it, expect } from 'vitest';
+import { nextSize } from '../src/composables/useSplitter';
+
+describe('useSplitter · nextSize', () => {
+ it('ajoute le delta de position (sens direct)', () => {
+ expect(nextSize(200, 100, 150, { min: 100, max: 400 })).toBe(250);
+ });
+
+ it('soustrait le delta quand invert (poignée à droite/bas)', () => {
+ expect(nextSize(200, 100, 150, { min: 100, max: 400, invert: true })).toBe(150);
+ });
+
+ it('clampe au maximum', () => {
+ expect(nextSize(380, 100, 200, { min: 100, max: 400 })).toBe(400);
+ });
+
+ it('clampe au minimum', () => {
+ expect(nextSize(120, 200, 100, { min: 100, max: 400 })).toBe(100);
+ });
+
+ it('taille inchangée si la position ne bouge pas', () => {
+ expect(nextSize(300, 150, 150, { min: 100, max: 400 })).toBe(300);
+ });
+});