Skip to content

Commit b89d385

Browse files
authored
fix(web): confirm dialogs respond to Enter and await async actions (#1744)
* fix(web): confirm dialogs respond to Enter and await async actions The confirm dialog's initial focus was resolved from the Button component's $el, which is a text node in dev builds (the component has a template-root comment, so it renders as a fragment). Focus fell back to the header close button, so Enter cancelled instead of confirming. Resolve the initial focus with a CSS selector on the confirm button instead. ConfirmOptions now accepts an async action: the dialog stays open with a loading state (cancel/Esc/overlay suppressed) until the work settles. The archive-session, remove-workspace, and delete-provider confirms move from the menu components into App.vue so the dialog can await the actual client call. * fix(web): block superseding a confirm dialog while its action runs A second confirm() during an in-flight action would replace the busy dialog and inherit the global busy state, opening inert until the first action settled. Resolve the new request unconfirmed instead.
1 parent f27c3cd commit b89d385

11 files changed

Lines changed: 276 additions & 113 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Fix Enter not confirming modal confirmation dialogs in dev builds, and keep the dialog open with a loading state until the confirmed action (such as archiving a session) completes.

apps/kimi-web/src/App.vue

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import GlobalLoading from './components/GlobalLoading.vue';
2727
import DebugPanel from './debug/DebugPanel.vue';
2828
import { isTraceEnabled } from './debug/trace';
2929
import { useKimiWebClient } from './composables/useKimiWebClient';
30+
import { useConfirmDialog } from './composables/useConfirmDialog';
3031
import { useAuthGate } from './composables/useAuthGate';
3132
import { usePageTitle } from './composables/usePageTitle';
3233
import { useSidebarLayout } from './composables/useSidebarLayout';
@@ -72,6 +73,7 @@ provide(
7273
(toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [],
7374
);
7475
const { t } = useI18n();
76+
const { confirm } = useConfirmDialog();
7577
7678
// KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1.
7779
const debugEnabled = isTraceEnabled();
@@ -411,14 +413,43 @@ async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl
411413
await client.addProvider(input);
412414
}
413415
414-
async function handleDeleteProvider(id: string): Promise<void> {
415-
await client.deleteProvider(id);
416-
}
417-
418416
async function handleRefreshProvider(id: string): Promise<void> {
419417
await client.refreshProvider(id);
420418
}
421419
420+
// Destructive session/workspace/provider actions confirm through the shared
421+
// modal here (the menu components only emit the intent). Each passes its work
422+
// as the dialog `action`, so the dialog stays open with a loading state until
423+
// the operation settles. All three client calls toast their own errors and
424+
// never reject.
425+
async function confirmArchiveSession(id: string): Promise<void> {
426+
await confirm({
427+
title: t('sidebar.archive'),
428+
message: t('sidebar.archiveConfirm'),
429+
variant: 'danger',
430+
action: () => client.archiveSession(id),
431+
});
432+
}
433+
434+
async function confirmDeleteWorkspace(id: string): Promise<void> {
435+
const name = client.workspacesView.value.find((w) => w.id === id)?.name ?? id;
436+
await confirm({
437+
title: t('sidebar.removeWorkspace'),
438+
message: t('workspace.removeWorkspaceConfirm', { name }),
439+
variant: 'danger',
440+
action: () => client.deleteWorkspace(id),
441+
});
442+
}
443+
444+
async function confirmDeleteProvider(id: string): Promise<void> {
445+
await confirm({
446+
title: t('providers.delete'),
447+
message: t('providers.confirmDelete'),
448+
variant: 'danger',
449+
action: () => client.deleteProvider(id),
450+
});
451+
}
452+
422453
async function handleUpdateConfig(patch: Partial<AppConfig>): Promise<void> {
423454
configSaving.value = true;
424455
try {
@@ -706,11 +737,11 @@ function openPr(url: string): void {
706737
@select-workspace="client.openWorkspace($event)"
707738
@add-workspace="showAddWorkspace = true"
708739
@rename="(id, title) => client.renameSession(id, title)"
709-
@archive="(id) => client.archiveSession(id)"
740+
@archive="confirmArchiveSession($event)"
710741
@fork="(id) => client.forkSession(id)"
711742
@export="(id) => client.exportSession(id)"
712743
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
713-
@delete-workspace="(id) => client.deleteWorkspace(id)"
744+
@delete-workspace="confirmDeleteWorkspace($event)"
714745
@reorder-workspaces="client.reorderWorkspaces($event)"
715746
@set-workspace-sort-mode="client.setWorkspaceSortMode($event)"
716747
@load-more-sessions="(id) => void client.loadMoreSessions(id)"
@@ -812,7 +843,7 @@ function openPr(url: string): void {
812843
@refresh-git-status="client.activeSessionId.value && client.loadGitStatus(client.activeSessionId.value)"
813844
@rename-session="(id, title) => client.renameSession(id, title)"
814845
@fork-session="(id) => client.forkSession(id)"
815-
@archive-session="(id) => client.archiveSession(id)"
846+
@archive-session="confirmArchiveSession($event)"
816847
@export-session="(id) => client.exportSession(id)"
817848
@compact="client.compact()"
818849
@pick-model="openModelPicker()"
@@ -991,7 +1022,7 @@ function openPr(url: string): void {
9911022
:unavailable="providersUnavailable"
9921023
@add="handleAddProvider($event)"
9931024
@refresh="handleRefreshProvider($event)"
994-
@delete="handleDeleteProvider($event)"
1025+
@delete="confirmDeleteProvider($event)"
9951026
@open-login="() => { showProviders = false; openLogin(); }"
9961027
@close="showProviders = false"
9971028
/>
@@ -1056,8 +1087,8 @@ function openPr(url: string): void {
10561087
@create-in-workspace="handleCreateSessionInWorkspace($event)"
10571088
@add-workspace="showAddWorkspace = true"
10581089
@rename="(id, title) => client.renameSession(id, title)"
1059-
@archive="(id) => client.archiveSession(id)"
1060-
@delete-workspace="(id) => client.deleteWorkspace(id)"
1090+
@archive="confirmArchiveSession($event)"
1091+
@delete-workspace="confirmDeleteWorkspace($event)"
10611092
@load-more="(id) => void client.loadMoreSessions(id)"
10621093
/>
10631094

apps/kimi-web/src/components/SessionRow.vue

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ import type { Session } from '../types';
88
import { copyTextToClipboard } from '../lib/clipboard';
99
import Spinner from './ui/Spinner.vue';
1010
import Badge from './ui/Badge.vue';
11-
import { useConfirmDialog } from '../composables/useConfirmDialog';
1211
import IconButton from './ui/IconButton.vue';
1312
import Menu from './ui/Menu.vue';
1413
import MenuItem from './ui/MenuItem.vue';
1514
import Icon from './ui/Icon.vue';
1615
import Tooltip from './ui/Tooltip.vue';
1716
1817
const { t } = useI18n();
19-
const { confirm } = useConfirmDialog();
2018
2119
const props = withDefaults(
2220
defineProps<{
@@ -168,18 +166,11 @@ function exportRow(): void {
168166
emit('export', props.session.id);
169167
}
170168
171-
// Archive confirm — modal, consistent with remove-workspace.
172-
async function startArchive(): Promise<void> {
169+
// Archive — the modal confirm and the async work live in App.vue
170+
// (confirmArchiveSession); the row only emits the intent.
171+
function startArchive(): void {
173172
closeMenu();
174-
if (
175-
await confirm({
176-
title: t('sidebar.archive'),
177-
message: t('sidebar.archiveConfirm'),
178-
variant: 'danger',
179-
})
180-
) {
181-
emit('archive', props.session.id);
182-
}
173+
emit('archive', props.session.id);
183174
}
184175
185176
// Expose closeMenu so the parent can close on outside-click.

apps/kimi-web/src/components/Sidebar.vue

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@ import Kbd from './ui/Kbd.vue';
3030
import Menu from './ui/Menu.vue';
3131
import MenuItem from './ui/MenuItem.vue';
3232
import Pill from './ui/Pill.vue';
33-
import { useConfirmDialog } from '../composables/useConfirmDialog';
3433
3534
const { t } = useI18n();
36-
const { confirm } = useConfirmDialog();
3735
3836
// Dev-only affordance: when the page is served by the Vite dev server, the
3937
// logo turns yellow and a backend pill next to the brand shows the engine
@@ -371,19 +369,12 @@ function startRenameFromMenu(): void {
371369
closeGhMenu();
372370
}
373371
374-
async function deleteFromMenu(): Promise<void> {
372+
function deleteFromMenu(): void {
375373
const ws = ghMenuTarget.value;
376374
if (!ws) return;
377375
closeGhMenu();
378-
if (
379-
await confirm({
380-
title: t('sidebar.removeWorkspace'),
381-
message: t('workspace.removeWorkspaceConfirm', { name: ws.name }),
382-
variant: 'danger',
383-
})
384-
) {
385-
emit('deleteWorkspace', ws.id);
386-
}
376+
// The modal confirm + async delete live in App.vue (confirmDeleteWorkspace).
377+
emit('deleteWorkspace', ws.id);
387378
}
388379
389380
// ---------------------------------------------------------------------------
@@ -449,17 +440,10 @@ function startRenameWs(ws: WorkspaceView): void {
449440
closeWsMenu();
450441
}
451442
452-
async function deleteWs(ws: WorkspaceView): Promise<void> {
443+
function deleteWs(ws: WorkspaceView): void {
453444
closeWsMenu();
454-
if (
455-
await confirm({
456-
title: t('sidebar.removeWorkspace'),
457-
message: t('workspace.removeWorkspaceConfirm', { name: ws.name }),
458-
variant: 'danger',
459-
})
460-
) {
461-
emit('deleteWorkspace', ws.id);
462-
}
445+
// The modal confirm + async delete live in App.vue (confirmDeleteWorkspace).
446+
emit('deleteWorkspace', ws.id);
463447
}
464448
465449
// ---------------------------------------------------------------------------

apps/kimi-web/src/components/chat/ChatHeader.vue

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import MenuItem from '../ui/MenuItem.vue';
1212
import IconButton from '../ui/IconButton.vue';
1313
import Icon from '../ui/Icon.vue';
1414
import Tooltip from '../ui/Tooltip.vue';
15-
import { useConfirmDialog } from '../../composables/useConfirmDialog';
1615
1716
const { t } = useI18n();
18-
const { confirm } = useConfirmDialog();
1917
2018
const props = defineProps<{
2119
sessionId?: string;
@@ -210,21 +208,13 @@ function exportSession(): void {
210208
}
211209
212210
// ---------------------------------------------------------------------------
213-
// Archive — modal confirm (the header has no session row to swap, so use the
214-
// shared ConfirmDialog instead of the inline strip used in SessionRow).
211+
// Archive — the modal confirm and the async work live in App.vue
212+
// (confirmArchiveSession); the header only emits the intent.
215213
// ---------------------------------------------------------------------------
216-
async function startArchive(): Promise<void> {
214+
function startArchive(): void {
217215
if (!props.sessionId) return;
218216
closeMenu();
219-
if (
220-
await confirm({
221-
title: t('header.archiveSession'),
222-
message: t('sidebar.archiveConfirm'),
223-
variant: 'danger',
224-
})
225-
) {
226-
emit('archiveSession', props.sessionId);
227-
}
217+
emit('archiveSession', props.sessionId);
228218
}
229219
</script>
230220

apps/kimi-web/src/components/dialogs/ConfirmDialog.vue

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,11 @@
33
Dialog (height auto, right-aligned footer). The single confirmation surface
44
for user actions — driven app-wide by useConfirmDialog(). -->
55
<script setup lang="ts">
6-
import { onBeforeUnmount, ref } from 'vue';
6+
import { onBeforeUnmount } from 'vue';
77
import { useI18n } from 'vue-i18n';
88
import Dialog from '../ui/Dialog.vue';
99
import Button from '../ui/Button.vue';
1010
11-
const confirmButtonRef = ref<InstanceType<typeof Button> | null>(null);
12-
13-
function confirmButtonElement(): HTMLElement | null {
14-
const el = confirmButtonRef.value?.$el;
15-
return el instanceof HTMLElement ? el : null;
16-
}
17-
1811
const props = withDefaults(defineProps<{
1912
open: boolean;
2013
title: string;
@@ -37,6 +30,10 @@ const emit = defineEmits<{
3730
const { t } = useI18n();
3831
3932
function onCancel(): void {
33+
// While the confirm action runs (loading), every cancel path — Cancel
34+
// button, header close, Esc, overlay click — is inert so the dialog can't
35+
// be dismissed out from under the in-flight work.
36+
if (props.loading) return;
4037
emit('update:open', false);
4138
emit('cancel');
4239
}
@@ -70,11 +67,17 @@ onBeforeUnmount(() => {
7067
</script>
7168

7269
<template>
70+
<!-- initial-focus uses a selector, not the Button component's $el: Button
71+
has a template-root comment, so in dev builds it renders as a fragment
72+
whose $el is a text node (unfocusable) — focus would fall back to the
73+
header close button and Enter would cancel instead of confirm. -->
7374
<Dialog
7475
:open="open"
7576
:title="title"
7677
height="auto"
77-
:initial-focus="confirmButtonElement"
78+
initial-focus=".confirm-dialog__confirm"
79+
:close-on-esc="!loading"
80+
:close-on-overlay="!loading"
7881
@update:open="emit('update:open', $event)"
7982
@close="onCancel"
8083
>
@@ -84,7 +87,7 @@ onBeforeUnmount(() => {
8487
{{ cancelLabel ?? t('common.cancel') }}
8588
</Button>
8689
<Button
87-
ref="confirmButtonRef"
90+
class="confirm-dialog__confirm"
8891
:variant="variant"
8992
:loading="loading"
9093
@click="emit('confirm')"

apps/kimi-web/src/components/dialogs/ConfirmDialogHost.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
import { useConfirmDialog } from '../../composables/useConfirmDialog';
66
import ConfirmDialog from './ConfirmDialog.vue';
77
8-
const { current, settle } = useConfirmDialog();
8+
const { current, busy, settle, runAction } = useConfirmDialog();
9+
10+
// runAction never rejects (a failing action rejects the confirm() promise
11+
// instead), so the floating promise is safe to drop here.
12+
function onConfirm(): void {
13+
void runAction();
14+
}
915
</script>
1016

1117
<template>
@@ -16,7 +22,8 @@ const { current, settle } = useConfirmDialog();
1622
:confirm-label="current?.confirmLabel"
1723
:cancel-label="current?.cancelLabel"
1824
:variant="current?.variant"
19-
@confirm="settle(true)"
25+
:loading="busy"
26+
@confirm="onConfirm"
2027
@cancel="settle(false)"
2128
/>
2229
</template>

0 commit comments

Comments
 (0)