From bec73190004da2d6ed9006836d0e51553adba405 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 13:42:22 +0800 Subject: [PATCH] feat(web): redesign cron reminder as a message bubble (#1480) * feat(web): redesign cron reminder as a message bubble Restyle the cron trigger notice as a right-aligned user-style message bubble that shows the scheduled prompt in full (wrapping across lines), with a small meta row beneath it for the schedule, status, job id and run time. Extract a shared MessageTime component used by both user messages and the cron reminder so the timestamp format and click-to-expand behavior stay consistent, and give the CronCreate/CronList/CronDelete tools distinct calendar icons. * refactor(web): render cron reminders only as standalone turns Remove the embedded cron block path from the web transcript projector so cron reminder fires always render through the standalone right-aligned bubble path. * chore(web): simplify cron redesign changeset --- .changeset/web-cron-redesign.md | 5 + .../kimi-web/src/components/chat/ChatPane.vue | 59 +----- .../src/components/chat/CronNotice.vue | 175 +++++++++--------- .../src/components/chat/MessageTime.vue | 62 +++++++ .../src/components/chatTurnRendering.ts | 7 +- .../src/composables/messagesToTurns.ts | 27 +-- apps/kimi-web/src/lib/icons.ts | 15 ++ apps/kimi-web/src/lib/toolMeta.ts | 4 + apps/kimi-web/src/types.ts | 3 +- apps/kimi-web/test/turn-logic.test.ts | 50 ----- 10 files changed, 182 insertions(+), 225 deletions(-) create mode 100644 .changeset/web-cron-redesign.md create mode 100644 apps/kimi-web/src/components/chat/MessageTime.vue diff --git a/.changeset/web-cron-redesign.md b/.changeset/web-cron-redesign.md new file mode 100644 index 000000000..d338b601c --- /dev/null +++ b/.changeset/web-cron-redesign.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Redesign the scheduled reminder UI. diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 7103a8cb8..47307bccf 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -9,13 +9,13 @@ import Markdown from './Markdown.vue'; import ThinkingBlock from './ThinkingBlock.vue'; import ActivityNotice from './ActivityNotice.vue'; import CronNotice from './CronNotice.vue'; +import MessageTime from './MessageTime.vue'; import AuthMedia from './AuthMedia.vue'; import MoonSpinner from '../ui/MoonSpinner.vue'; import Spinner from '../ui/Spinner.vue'; import Icon from '../ui/Icon.vue'; import Tooltip from '../ui/Tooltip.vue'; import { useConfirmDialog } from '../../composables/useConfirmDialog'; -import { formatMessageTime } from '../../lib/formatMessageTime'; import { copyTextToClipboard } from '../../lib/clipboard'; import { assistantRenderBlocks, @@ -318,27 +318,6 @@ const undoingTurnId = ref(null); let undoFallbackTimer: ReturnType | null = null; const UNDO_FALLBACK_MS = 2500; -// Expanded timestamp state (keyed by turn id) -const expandedTimeTurnIds = ref>(new Set()); -function isTimeExpanded(turnId: string): boolean { - return expandedTimeTurnIds.value.has(turnId); -} -function toggleTime(turnId: string): void { - const next = new Set(expandedTimeTurnIds.value); - if (next.has(turnId)) next.delete(turnId); - else next.add(turnId); - expandedTimeTurnIds.value = next; -} -function displayMessageTime(iso: string, turnId: string): string { - if (isTimeExpanded(turnId)) { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - const pad2 = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; - } - return formatMessageTime(iso, t('conversation.yesterday')); -} - async function onUndo(turn: ChatTurn): Promise { if ( await confirm({ @@ -611,14 +590,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): - + @@ -642,7 +614,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): - +
@@ -660,7 +632,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): @open-agent="emit('openAgent', $event)" /> -
@@ -855,29 +826,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): margin-top: 2px; margin-right: 4px; } -.u-meta .u-time { - display: inline-flex; - align-items: center; - padding: 2px 5px; - background: none; - border: none; - border-radius: var(--radius-sm); - color: var(--muted); - font: inherit; - font-size: var(--text-base); - line-height: 1; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.12s, color 0.12s, background-color 0.12s; - white-space: nowrap; -} -.u-meta .u-time:hover { - opacity: 1; - color: var(--color-accent); - background: var(--hover); -} -.u-meta .u-edit, -.u-meta .u-time { +.u-meta .u-edit { min-height: 22px; box-sizing: border-box; } diff --git a/apps/kimi-web/src/components/chat/CronNotice.vue b/apps/kimi-web/src/components/chat/CronNotice.vue index e4f8b52e5..4b2084e90 100644 --- a/apps/kimi-web/src/components/chat/CronNotice.vue +++ b/apps/kimi-web/src/components/chat/CronNotice.vue @@ -1,19 +1,23 @@ - + embedded inside an assistant turn's blocks — in both cases it takes the + same text + cron data. --> diff --git a/apps/kimi-web/src/components/chat/MessageTime.vue b/apps/kimi-web/src/components/chat/MessageTime.vue new file mode 100644 index 000000000..daa6a0345 --- /dev/null +++ b/apps/kimi-web/src/components/chat/MessageTime.vue @@ -0,0 +1,62 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chatTurnRendering.ts b/apps/kimi-web/src/components/chatTurnRendering.ts index 01d13025a..f928adf2d 100644 --- a/apps/kimi-web/src/components/chatTurnRendering.ts +++ b/apps/kimi-web/src/components/chatTurnRendering.ts @@ -2,7 +2,7 @@ // Pure turn-rendering helpers: pure functions of their arguments (no Vue // reactivity, no component state). Shared by ChatPane.vue's template and its // stateful copy/edit helpers. -import type { ChatTurn, CronTurnData, TurnBlock } from '../types'; +import type { ChatTurn, TurnBlock } from '../types'; export function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -41,8 +41,7 @@ export type AssistantRenderBlock = | { kind: 'thinking'; thinking: string; sourceIndex: number } | { kind: 'text'; text: string; sourceIndex: number } | { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number } - | { kind: 'tool-stack'; tools: ToolStackItem[] } - | { kind: 'cron'; text: string; cron: CronTurnData; sourceIndex: number }; + | { kind: 'tool-stack'; tools: ToolStackItem[] }; export function rendersToolCard(block: Extract): boolean { return !(block.tool.status === 'ok' && block.tool.media); @@ -86,8 +85,6 @@ export function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] { rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex }); } else if (block.kind === 'text') { rendered.push({ kind: 'text', text: block.text, sourceIndex }); - } else if (block.kind === 'cron') { - rendered.push({ kind: 'cron', text: block.text, cron: block.cron, sourceIndex }); } }); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 4293cb0be..8d2993a22 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -415,10 +415,6 @@ function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_mis return { id: msg.id, role: 'cron', no, text, createdAt: msg.createdAt, cron }; } -function buildCronBlock(msg: AppMessage, kind: 'cron_job' | 'cron_missed'): TurnBlock { - const { text, cron } = buildCronData(msg, kind); - return { kind: 'cron', text, cron }; -} /** * Whether a USER-role message should be shown. Mirrors the TUI's @@ -457,12 +453,6 @@ function continuesAssistantGroup(group: Group | null, promptId: string | undefin ); } -/** True while a tool in this group has been called but not yet resolved — i.e. - * the group is mid-tool-call. A cron injection that arrives now is sandwiched - * between the tool use and its result and must not flush the group. */ -function hasRunningTool(group: Group): boolean { - return group.tools.some((t) => t.status === 'running'); -} /** Extract the plan file path from an ExitPlanMode tool result. The approved * output contains `Plan saved to: `; this survives a page reload (unlike @@ -653,19 +643,10 @@ export function messagesToTurns( // User messages flush the pending group and start a new user turn if (msg.role === 'user') { const cronKind = cronOriginKind(msg); - // A cron injection steered into an in-flight turn can land between a - // tool use and its result in the live event stream. It must NOT flush the - // pending assistant group then — flushing would orphan the next - // tool.result, which only folds into a pending group, leaving the tool - // rendered without output. So embed it as a block only while the group - // has an in-flight tool. Otherwise — a cron at a turn boundary, including - // an idle fire on a REST snapshot that carries no prompt ids (where the - // whole transcript shares one group) — flush and render it as its own - // turn so it doesn't merge into the previous answer. - if (cronKind !== undefined && pendingGroup !== null && hasRunningTool(pendingGroup)) { - pendingGroup.blocks.push(buildCronBlock(msg, cronKind)); - continue; - } + // A cron injection always renders as its own standalone turn: agent-core + // buffers steer input while a turn is in flight and only injects it at the + // turn boundary, so the cron message does not land between a tool use and + // its result in practice. flushGroup(); if (cronKind !== undefined) { turns.push(buildCronTurn(msg, no++, cronKind)); diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index 3511af539..e41f0b1e4 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -26,6 +26,9 @@ import RiArrowRightLine from '~icons/ri/arrow-right-line'; import RiArrowRightSLine from '~icons/ri/arrow-right-s-line'; import RiArrowUpLine from '~icons/ri/arrow-up-line'; import RiBracesLine from '~icons/ri/braces-line'; +import RiCalendarCloseLine from '~icons/ri/calendar-close-line'; +import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line'; +import RiCalendarTodoLine from '~icons/ri/calendar-todo-line'; import RiChatNewLine from '~icons/ri/chat-new-line'; import RiCheckLine from '~icons/ri/check-line'; import RiCloseLine from '~icons/ri/close-line'; @@ -84,6 +87,9 @@ import RawArrowRightLine from '~icons/ri/arrow-right-line?raw'; import RawArrowRightSLine from '~icons/ri/arrow-right-s-line?raw'; import RawArrowUpLine from '~icons/ri/arrow-up-line?raw'; import RawBracesLine from '~icons/ri/braces-line?raw'; +import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw'; +import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw'; +import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw'; import RawChatNewLine from '~icons/ri/chat-new-line?raw'; import RawCheckLine from '~icons/ri/check-line?raw'; import RawCloseLine from '~icons/ri/close-line?raw'; @@ -136,6 +142,9 @@ import RawUserLine from '~icons/ri/user-line?raw'; export type IconName = | 'plus' | 'chat-new' + | 'calendar-close' + | 'calendar-schedule' + | 'calendar-todo' | 'close' | 'check' | 'search' @@ -212,6 +221,9 @@ function entry(component: Component, svg: string): IconEntry { export const ICONS: Record = { plus: entry(RiAddLine, RawAddLine), 'chat-new': entry(RiChatNewLine, RawChatNewLine), + 'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine), + 'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine), + 'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine), close: entry(RiCloseLine, RawCloseLine), check: entry(RiCheckLine, RawCheckLine), search: entry(RiSearchLine, RawSearchLine), @@ -353,6 +365,9 @@ export const ICON_GROUPS: ReadonlyArray 'check-list', 'bolt', 'git-pull-request', + 'calendar-schedule', + 'calendar-todo', + 'calendar-close', ], ], ['Communication', ['message', 'mail', 'user']], diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 0c7c057e6..6ecfd4c00 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -93,6 +93,10 @@ const TOOL_GLYPH: Record = { task: 'sparkles', agentswarm: 'git-pull-request', askuserquestion: 'help-circle', + // Cron scheduling tools share a calendar motif: schedule / list / cancel. + croncreate: 'calendar-schedule', + cronlist: 'calendar-todo', + crondelete: 'calendar-close', }; export function toolGlyph(name: string): string { diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts index e40b58062..d98b3dbcf 100644 --- a/apps/kimi-web/src/types.ts +++ b/apps/kimi-web/src/types.ts @@ -229,8 +229,7 @@ export interface CronTurnData { export type TurnBlock = | { kind: 'text'; text: string } | { kind: 'thinking'; thinking: string } - | { kind: 'tool'; tool: ToolCall } - | { kind: 'cron'; text: string; cron: CronTurnData }; + | { kind: 'tool'; tool: ToolCall }; export interface ChatTurn { id: string; diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts index b28d93b5d..07b09bae7 100644 --- a/apps/kimi-web/test/turn-logic.test.ts +++ b/apps/kimi-web/test/turn-logic.test.ts @@ -320,56 +320,6 @@ describe('messagesToTurns cron', () => { expect(turns).toHaveLength(1); }); - it('embeds an in-turn cron injection as a block and keeps folding the following tool result', () => { - const envelope = - '\n' + - '\nCheck BTC\n\n'; - const turns = messagesToTurns( - [ - message('u1', 'user', [{ type: 'text', text: 'hi' }]), - message('a1', 'assistant', [ - { - type: 'toolUse', - toolCallId: 'tc1', - toolName: 'FetchURL', - input: { url: 'https://example.com' }, - }, - ]), - message('c1', 'user', [{ type: 'text', text: envelope }], { - metadata: { - origin: { - kind: 'cron_job', - jobId: 'j', - cron: '* * * * *', - recurring: true, - coalescedCount: 1, - stale: false, - }, - }, - }), - message('t1', 'tool', [ - { type: 'toolResult', toolCallId: 'tc1', output: 'the price is 62k' }, - ]), - ], - [], - ); - - // user turn + one assistant turn (tool + embedded cron block + result). - // No standalone cron turn, and the tool result is preserved. - expect(turns).toHaveLength(2); - const assistant = turns[1]!; - expect(assistant.role).toBe('assistant'); - expect(assistant.tools?.[0]).toMatchObject({ - id: 'tc1', - status: 'ok', - output: ['the price is 62k'], - }); - expect(assistant.blocks?.find((b) => b.kind === 'cron')).toMatchObject({ - kind: 'cron', - text: 'Check BTC', - cron: { jobId: 'j' }, - }); - }); it('flushes an idle cron fire as its own turn even when no prompt ids are present', () => { const envelope =