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)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,36 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
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 {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited || !binding.controlling) return;
|
||||
|
||||
@@ -161,6 +161,22 @@ export function registerWsGateway(
|
||||
}
|
||||
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': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
||||
|
||||
@@ -73,6 +73,8 @@ type BindingSpies = ClientBinding & {
|
||||
onControlChanged: Mock<(controlling: boolean) => void>;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let channelSeq = 1;
|
||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
||||
return {
|
||||
@@ -290,6 +292,58 @@ describe('PtyManager (pty mocké)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('answer (P4-A)', () => {
|
||||
it('select → "N\\r" si l’option 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', () => {
|
||||
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -166,6 +166,10 @@ export type ClientMessage =
|
||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
||||
| { type: 'detach'; channel: number }
|
||||
| { 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: 'ack'; channel: number; bytes: number }
|
||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
||||
@@ -193,6 +197,7 @@ export type ErrorCode =
|
||||
| 'NOT_ATTACHED'
|
||||
| 'NOT_CONTROLLING'
|
||||
| 'SESSION_EXITED'
|
||||
| 'INVALID_ANSWER'
|
||||
| 'INTERNAL';
|
||||
|
||||
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
|
||||
? { type: 'stdin', channel: m.channel, data: m.data }
|
||||
: 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':
|
||||
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||
|
||||
@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
|
||||
expect(typeof msg.data).toBe('string');
|
||||
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
||||
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':
|
||||
expect(isU32(msg.channel)).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 });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
||||
@@ -168,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
describe('parseClientMessage — fuzz rapide', () => {
|
||||
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
||||
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[] = [
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
|
||||
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', 'select', 'confirm', 'deny', {}, [], ['sessions'],
|
||||
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
||||
];
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
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);
|
||||
}
|
||||
const raw = JSON.stringify(obj);
|
||||
|
||||
File diff suppressed because one or more lines are too long
71
packages/web/src/components/DialogPrompt.vue
Normal file
71
packages/web/src/components/DialogPrompt.vue
Normal 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>
|
||||
@@ -37,6 +37,9 @@
|
||||
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +51,7 @@ import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { ApiError } from '../lib/api';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
import DialogPrompt from './DialogPrompt.vue';
|
||||
|
||||
const props = defineProps<{ worktree: WorktreeSummary }>();
|
||||
const { t } = useI18n();
|
||||
@@ -60,6 +64,11 @@ function liveSession(s: SessionSummary): SessionSummary {
|
||||
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 forceNeeded = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
@@ -47,6 +47,16 @@ export default {
|
||||
idle: 'idle',
|
||||
waiting: 'waiting',
|
||||
},
|
||||
dialog: {
|
||||
waiting: 'Waiting for your input',
|
||||
deny: 'Deny (Esc)',
|
||||
kind: {
|
||||
trust: 'Trust',
|
||||
permission: 'Permission',
|
||||
question: 'Question',
|
||||
plan: 'Plan',
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Worktrees',
|
||||
|
||||
@@ -49,6 +49,16 @@ const fr: typeof en = {
|
||||
idle: 'inactive',
|
||||
waiting: 'en attente',
|
||||
},
|
||||
dialog: {
|
||||
waiting: 'En attente de votre saisie',
|
||||
deny: 'Refuser (Échap)',
|
||||
kind: {
|
||||
trust: 'Confiance',
|
||||
permission: 'Permission',
|
||||
question: 'Question',
|
||||
plan: 'Plan',
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Worktrees',
|
||||
|
||||
@@ -75,6 +75,16 @@ export class Attachment {
|
||||
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 */
|
||||
resize(cols: number, rows: number): void {
|
||||
this.cols = cols;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</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" />
|
||||
<!-- 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">
|
||||
@@ -26,6 +27,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<DialogPrompt v-if="s.live && s.activity === 'waiting'" :session="s" />
|
||||
<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.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
||||
@@ -110,6 +111,7 @@ import { useAuthStore } from '../stores/auth';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
Reference in New Issue
Block a user