feat(kimi-web): esc interrupt, abort toast, and filepath link fixes

This commit is contained in:
qer 2026-06-11 16:31:39 +08:00
parent fca2abe4b1
commit 35dbd29528
9 changed files with 114 additions and 10 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-web": minor
---
Add Escape key support to interrupt running prompts and show a transient "manually stopped" toast.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-web": patch
---
Ignore branch-like slash names without file extensions when detecting file path links.

View file

@ -501,7 +501,6 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
height: auto;
margin-top: 10px;
overflow: visible;
cursor: pointer;
}
.a-cpbtn {
@ -519,20 +518,22 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
.a-cpbtn:hover {
color: var(--ink);
}
.a-cpbtn svg,
.a-cpbtn-text {
pointer-events: none;
}
.a-cpbtn svg {
flex: none;
}
.a-cpbtn-text {
opacity: 0;
max-width: 0;
overflow: hidden;
max-width: none;
overflow: visible;
white-space: nowrap;
transition: opacity 0.15s ease, max-width 0.15s ease;
cursor: pointer;
transition: opacity 0.15s ease;
}
.a-cpbtn:hover .a-cpbtn-text {
opacity: 1;
max-width: 120px;
}
/* Touch devices: always show the copy buttons (no hover to reveal them) and
give the bubble-layout button a comfortable tap size. */
@ -604,7 +605,7 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
/* Mobile bubble layout sending placeholder */
.sending-placeholder {
align-self: flex-start;
padding: 10px 14px;
padding: 10px 0;
}
/* Desktop line-turns sending placeholder */

View file

@ -474,9 +474,25 @@ function onVisibilityChange(): void {
}
}
// ---------------------------------------------------------------------------
// Manual-abort toast: shown when the user presses Escape to stop the prompt
// ---------------------------------------------------------------------------
const abortToastVisible = ref(false);
let abortToastTimer: ReturnType<typeof setTimeout> | null = null;
const ABORT_TOAST_DURATION = 3000;
function showAbortToast(): void {
abortToastVisible.value = true;
if (abortToastTimer !== null) clearTimeout(abortToastTimer);
abortToastTimer = setTimeout(() => {
abortToastVisible.value = false;
}, ABORT_TOAST_DURATION);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape' && props.running) {
if (event.key === 'Escape' && (props.running || props.sending)) {
event.preventDefault();
showAbortToast();
emit('interrupt');
}
}
@ -510,6 +526,7 @@ onUnmounted(() => {
if (contentObserver) contentObserver.disconnect();
if (resizeObserver) resizeObserver.disconnect();
if (scrollRaf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(scrollRaf);
if (abortToastTimer !== null) clearTimeout(abortToastTimer);
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('keydown', onKeyDown);
@ -763,6 +780,18 @@ onUnmounted(() => {
@select-model="emit('selectModel', $event)"
/>
</div>
<!-- Manual-abort toast: shown when the user presses Escape to stop a prompt -->
<Transition name="abort-toast">
<div
v-if="abortToastVisible"
class="abort-toast"
role="status"
aria-live="polite"
>
<span class="abort-toast-text">{{ t('conversation.manuallyAborted') }}</span>
</div>
</Transition>
</section>
</template>
@ -1100,4 +1129,48 @@ onUnmounted(() => {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
/* Manual-abort toast: centered near the top of the conversation pane */
.abort-toast {
position: fixed;
top: 56px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
padding: 8px 18px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.12);
font-size: 14px;
font-family: var(--sans);
color: var(--ink);
white-space: nowrap;
pointer-events: none;
}
.abort-toast-text {
display: flex;
align-items: center;
gap: 6px;
}
.abort-toast-text::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
/* Abort-toast enter/leave transition */
.abort-toast-enter-active,
.abort-toast-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.abort-toast-enter-from,
.abort-toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-6px);
}
</style>

View file

@ -433,15 +433,21 @@ function copyDiff(code: string, idx: number) {
font-size: 10px;
}
/* Copy button in the header */
.md :deep(.copy-button) {
.md :deep(.code-block-header .copy-button),
.md :deep(.code-block-header .code-action-btn) {
color: var(--muted);
background: none;
border: none;
cursor: pointer;
}
.md :deep(.copy-button:hover) {
.md :deep(.code-block-header .copy-button:hover),
.md :deep(.code-block-header .code-action-btn:hover) {
color: var(--blue);
}
.md :deep(.code-block-header .copy-button *),
.md :deep(.code-block-header .code-action-btn *) {
pointer-events: none;
}
.md :deep(.code-block-content),
.md :deep(.markstream-pre) {
background: var(--panel);

View file

@ -6,4 +6,5 @@ export default {
compacting: 'Compacting context…',
compacted: 'Context compacted ({before} → {after} tokens)',
compactedPlain: 'Context compacted',
manuallyAborted: 'Manually stopped',
} as const;

View file

@ -6,4 +6,5 @@ export default {
compacting: '正在压缩上下文…',
compacted: '上下文已压缩({before} → {after} tokens',
compactedPlain: '上下文已压缩',
manuallyAborted: '您已手动终止',
} as const;

View file

@ -75,6 +75,11 @@ const PATH_RE = new RegExp(
const TRAILING_PUNCTUATION_RE = /[),.;!?,。;!?)]+$/;
function hasCommonFileExtension(path: string): boolean {
const lower = path.toLowerCase();
return COMMON_FILE_EXTENSIONS.some((ext) => lower.endsWith(`.${ext}`));
}
export function collectFilePathAliases(text: string): Map<string, string> {
const aliases = new Map<string, string>();
const attrPathRe = new RegExp(
@ -106,6 +111,8 @@ export function parseFilePathLinkCandidate(
const basename = path.split('/').pop() ?? path;
const hasSeparator = path.includes('/');
const hasKnownName = COMMON_FILENAMES.has(basename);
const hasKnownExtension = hasCommonFileExtension(basename);
if (hasSeparator && !hasKnownName && !hasKnownExtension) return null;
if (!hasSeparator && !hasKnownName) {
const alias = options.aliases?.get(basename);
if (!alias) return null;

View file

@ -42,6 +42,11 @@ describe('file path links', () => {
expect(parseFilePathLinkCandidate('hello')).toBeNull();
});
it('ignores branch-like slash names without file extensions', () => {
expect(parseFilePathLinkCandidate('feat/web')).toBeNull();
expect(findFilePathLinks('commit db8d21cd on feat/web.')).toEqual([]);
});
it('finds multiple links in message text', () => {
expect(findFilePathLinks('See apps/kimi-web/src/App.vue:11 and package.json.')).toEqual([
{