P4-A: commande WS answer — répondre aux dialogues sans clavier

Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).

- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
  intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)
This commit is contained in:
2026-06-15 12:05:27 +02:00
parent 49a31d999b
commit 25d6d09d24
14 changed files with 265 additions and 6 deletions

View File

@@ -256,6 +256,36 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return 'ok'; return 'ok';
} }
/**
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
* - 'deny' : refus universel (Esc).
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
*/
answer(
sessionId: string,
binding: ClientBinding,
action: 'select' | 'confirm' | 'deny',
optionN?: number,
): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
if (binding.mode !== 'interactive') return 'not_controlling';
const act = s.tracker?.snapshot();
if (action === 'select') {
// L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
s.proc.write(`${optionN}\r`);
return 'ok';
}
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
}
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void { resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
const s = this.live.get(sessionId); const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return; if (!s || s.exited || !binding.controlling) return;

View File

@@ -161,6 +161,22 @@ export function registerWsGateway(
} }
return; return;
} }
case 'answer': {
const st = channels.get(msg.channel);
if (!st) {
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
return;
}
const r = manager.answer(st.sessionId, st.binding, msg.action, msg.optionN);
if (r === 'not_controlling') {
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
} else if (r === 'gone') {
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
} else if (r === 'invalid') {
send({ type: 'error', code: 'INVALID_ANSWER', message: 'No such option in the current dialog', channel: msg.channel });
}
return;
}
case 'resize': { case 'resize': {
const st = channels.get(msg.channel); const st = channels.get(msg.channel);
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows); if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);

View File

@@ -73,6 +73,8 @@ type BindingSpies = ClientBinding & {
onControlChanged: Mock<(controlling: boolean) => void>; onControlChanged: Mock<(controlling: boolean) => void>;
}; };
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
let channelSeq = 1; let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies { function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
return { return {
@@ -290,6 +292,58 @@ describe('PtyManager (pty mocké)', () => {
}); });
}); });
describe('answer (P4-A)', () => {
it('select → "N\\r" si loption existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-'));
const m = new PtyManager(db, sessDir);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
// écran : dialogue de permission à 2 options (la 1re est sélectionnée )
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\nEsc to cancel\r\n');
writeFileSync(
join(sessDir, `${p.pid}.json`),
JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }),
);
await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir létat
const a = makeBinding('interactive');
m.attach(summary.id, a, 80, 24);
p.write.mockClear();
// option inexistante → invalid (anti-frappe fantôme), aucun write
expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid');
expect(p.write).not.toHaveBeenCalled();
// option existante → "1\r"
expect(m.answer(summary.id, a, 'select', 1)).toBe('ok');
expect(p.write).toHaveBeenCalledWith('1\r');
// deny → Esc
expect(m.answer(summary.id, a, 'deny')).toBe('ok');
expect(p.write).toHaveBeenCalledWith('\x1b');
// observer read-only
const obs = makeBinding('observer');
m.attach(summary.id, obs, 80, 24);
expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling');
// session inconnue → gone
expect(m.answer('nope', a, 'deny')).toBe('gone');
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
it('session sans état waiting (bash, pas de tracker) → invalid', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.write.mockClear();
expect(manager.answer(summary.id, a, 'deny')).toBe('invalid');
expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid');
expect(pty.write).not.toHaveBeenCalled();
});
});
describe('flow control', () => { describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => { it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash(); const { summary, pty } = spawnBash();

File diff suppressed because one or more lines are too long

View File

@@ -166,6 +166,10 @@ export type ClientMessage =
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number } | { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
| { type: 'detach'; channel: number } | { type: 'detach'; channel: number }
| { type: 'stdin'; channel: number; data: string } | { type: 'stdin'; channel: number; data: string }
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
// en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny')
// et valide l'option contre le dialogue courant (anti-frappe fantôme mobile).
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
| { type: 'resize'; channel: number; cols: number; rows: number } | { type: 'resize'; channel: number; cols: number; rows: number }
| { type: 'ack'; channel: number; bytes: number } | { type: 'ack'; channel: number; bytes: number }
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> } | { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
@@ -193,6 +197,7 @@ export type ErrorCode =
| 'NOT_ATTACHED' | 'NOT_ATTACHED'
| 'NOT_CONTROLLING' | 'NOT_CONTROLLING'
| 'SESSION_EXITED' | 'SESSION_EXITED'
| 'INVALID_ANSWER'
| 'INTERNAL'; | 'INTERNAL';
export function parseClientMessage(raw: string): ClientMessage | null { export function parseClientMessage(raw: string): ClientMessage | null {
@@ -220,6 +225,16 @@ export function parseClientMessage(raw: string): ClientMessage | null {
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536 return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
? { type: 'stdin', channel: m.channel, data: m.data } ? { type: 'stdin', channel: m.channel, data: m.data }
: null; : null;
case 'answer': {
if (!isU32(m.channel)) return null;
if (m.action === 'select')
return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99
? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number }
: null;
return m.action === 'confirm' || m.action === 'deny'
? { type: 'answer', channel: m.channel, action: m.action }
: null;
}
case 'resize': case 'resize':
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows) return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows } ? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }

View File

@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
expect(typeof msg.data).toBe('string'); expect(typeof msg.data).toBe('string');
expect(msg.data.length).toBeLessThanOrEqual(65536); expect(msg.data.length).toBeLessThanOrEqual(65536);
break; break;
case 'answer':
expect(isU32(msg.channel)).toBe(true);
expect(['select', 'confirm', 'deny']).toContain(msg.action);
if (msg.action === 'select') {
expect(typeof msg.optionN).toBe('number');
expect(msg.optionN).toBeGreaterThanOrEqual(1);
expect(msg.optionN).toBeLessThanOrEqual(99);
}
break;
case 'resize': case 'resize':
expect(isU32(msg.channel)).toBe(true); expect(isU32(msg.channel)).toBe(true);
expect(isDim(msg.cols)).toBe(true); expect(isDim(msg.cols)).toBe(true);
@@ -92,6 +101,18 @@ describe('parseClientMessage — cas valides', () => {
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 }); .toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
}); });
it('answer select / confirm / deny', () => {
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
});
it('ack à zéro octet', () => { it('ack à zéro octet', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 }); expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
}); });
@@ -153,6 +174,15 @@ describe('parseClientMessage — cas malformés', () => {
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull(); expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
}); });
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
});
it('ack : bytes négatif ou non numérique', () => { it('ack : bytes négatif ou non numérique', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull(); expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull(); expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
@@ -168,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
describe('parseClientMessage — fuzz rapide', () => { describe('parseClientMessage — fuzz rapide', () => {
it('ne lève jamais et tout message accepté respecte les invariants', () => { it('ne lève jamais et tout message accepté respecte les invariants', () => {
const rand = mulberry32(0xa5b0e7); const rand = mulberry32(0xa5b0e7);
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null]; const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
const values: unknown[] = [ const values: unknown[] = [
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000, undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'], -0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000), ['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
]; ];
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T; const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
const obj: Record<string, unknown> = { type: pick(types) }; const obj: Record<string, unknown> = { type: pick(types) };
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) { for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
if (rand() < 0.7) obj[key] = pick(values); if (rand() < 0.7) obj[key] = pick(values);
} }
const raw = JSON.stringify(obj); const raw = JSON.stringify(obj);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,71 @@
<template>
<div v-if="dialog" class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-3">
<div class="flex items-center gap-2">
<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">
<button
v-for="opt in dialog.options"
:key="opt.n"
class="btn 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 }}
</button>
<button class="btn-danger text-xs" :disabled="busy" @click="respond('deny')">
{{ t('sessions.dialog.deny') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
// P4-A : répondre à un dialogue Claude sans ouvrir le terminal. Le composant gère sa
// propre attache interactive LÉGÈRE (sink no-op, sans xterm), créée à la demande au
// premier clic et libérée au démontage — c'est ce qui réalise la réponse « mobile ».
import { computed, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { wsClient, type Attachment, type TerminalSink } from '../lib/ws-client';
import { ref } from 'vue';
const props = defineProps<{ session: SessionSummary }>();
const { t } = useI18n();
const busy = ref(false);
const dialog = computed(() => (props.session.activity === 'waiting' ? props.session.dialog ?? null : null));
// Sink no-op : l'attache de réponse n'affiche rien ; elle rappelle le callback du flow
// control pour ne jamais bloquer le PTY côté serveur.
const noopSink: TerminalSink = {
write: (_data, cb) => cb?.(),
reset: () => {},
onDetached: () => {},
onControlChanged: () => {},
};
let attachPromise: Promise<Attachment> | null = null;
function ensureAttached(): Promise<Attachment> {
attachPromise ??= wsClient.attach({ sessionId: props.session.id, mode: 'interactive', cols: 120, rows: 32, sink: noopSink });
return attachPromise;
}
async function respond(action: 'select' | 'confirm' | 'deny', optionN?: number): Promise<void> {
busy.value = true;
try {
const att = await ensureAttached();
att.answer(action, optionN);
} catch {
// attache impossible (session disparue) : l'état réel arrive par session_update
} finally {
busy.value = false;
}
}
onUnmounted(() => {
attachPromise?.then((a) => a.detach()).catch(() => {});
});
</script>

View File

@@ -37,6 +37,9 @@
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button> <button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
</div> </div>
</div> </div>
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
</div> </div>
</template> </template>
@@ -48,6 +51,7 @@ import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { ApiError } from '../lib/api'; import { ApiError } from '../lib/api';
import SessionStateBadge from './SessionStateBadge.vue'; import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.vue';
const props = defineProps<{ worktree: WorktreeSummary }>(); const props = defineProps<{ worktree: WorktreeSummary }>();
const { t } = useI18n(); const { t } = useI18n();
@@ -60,6 +64,11 @@ 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 corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
const waitingSessions = computed(() =>
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
);
const confirming = ref(false); const confirming = ref(false);
const forceNeeded = ref(false); const forceNeeded = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);

View File

@@ -47,6 +47,16 @@ export default {
idle: 'idle', idle: 'idle',
waiting: 'waiting', waiting: 'waiting',
}, },
dialog: {
waiting: 'Waiting for your input',
deny: 'Deny (Esc)',
kind: {
trust: 'Trust',
permission: 'Permission',
question: 'Question',
plan: 'Plan',
},
},
}, },
dashboard: { dashboard: {
title: 'Worktrees', title: 'Worktrees',

View File

@@ -49,6 +49,16 @@ const fr: typeof en = {
idle: 'inactive', idle: 'inactive',
waiting: 'en attente', waiting: 'en attente',
}, },
dialog: {
waiting: 'En attente de votre saisie',
deny: 'Refuser (Échap)',
kind: {
trust: 'Confiance',
permission: 'Permission',
question: 'Question',
plan: 'Plan',
},
},
}, },
dashboard: { dashboard: {
title: 'Worktrees', title: 'Worktrees',

View File

@@ -75,6 +75,16 @@ export class Attachment {
this.client.sendControl({ type: 'stdin', channel: this.channel, data }); this.client.sendControl({ type: 'stdin', channel: this.channel, data });
} }
/** Répond à un dialogue Claude (P4-A) sans clavier ; le serveur traduit en keystrokes. */
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
if (this.closed || this.channel < 0) return;
this.client.sendControl(
action === 'select' && optionN !== undefined
? { type: 'answer', channel: this.channel, action, optionN }
: { type: 'answer', channel: this.channel, action },
);
}
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */ /** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
resize(cols: number, rows: number): void { resize(cols: number, rows: number): void {
this.cols = cols; this.cols = cols;

View File

@@ -12,6 +12,7 @@
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</header> </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" /> <TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). --> <!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
<div v-else class="flex flex-1 items-center justify-center p-6 text-center"> <div v-else class="flex flex-1 items-center justify-center p-6 text-center">
@@ -26,6 +27,7 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import TerminalView from '../components/TerminalView.vue'; import TerminalView from '../components/TerminalView.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import LanguageSwitcher from '../components/LanguageSwitcher.vue'; import LanguageSwitcher from '../components/LanguageSwitcher.vue';
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -64,6 +64,7 @@
</div> </div>
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p> <p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p> <p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
<DialogPrompt v-if="s.live && s.activity === 'waiting'" :session="s" />
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500"> <div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span> <span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span> <span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
@@ -110,6 +111,7 @@ import { useAuthStore } from '../stores/auth';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import LanguageSwitcher from '../components/LanguageSwitcher.vue'; import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import SessionStateBadge from '../components/SessionStateBadge.vue'; import SessionStateBadge from '../components/SessionStateBadge.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();