mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
This commit is contained in:
parent
b30a45efec
commit
2ad0120c2a
10 changed files with 182 additions and 225 deletions
5
.changeset/web-cron-redesign.md
Normal file
5
.changeset/web-cron-redesign.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Redesign the scheduled reminder UI.
|
||||
|
|
@ -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<string | null>(null);
|
|||
let undoFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const UNDO_FALLBACK_MS = 2500;
|
||||
|
||||
// Expanded timestamp state (keyed by turn id)
|
||||
const expandedTimeTurnIds = ref<Set<string>>(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<void> {
|
||||
if (
|
||||
await confirm({
|
||||
|
|
@ -611,14 +590,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
|
||||
<Icon v-else name="check" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
v-if="turn.createdAt"
|
||||
type="button"
|
||||
class="u-time"
|
||||
@click.stop="toggleTime(turn.id)"
|
||||
>
|
||||
{{ displayMessageTime(turn.createdAt, turn.id) }}
|
||||
</button>
|
||||
<MessageTime v-if="turn.createdAt" :time="turn.createdAt" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -642,7 +614,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
|
||||
<!-- Cron notice — a turn triggered by a scheduled reminder, rendered as
|
||||
a lightweight in-transcript notice rather than a user bubble. -->
|
||||
<CronNotice v-else-if="turn.role === 'cron'" :text="turn.text" :cron="turn.cron" :turn-id="turn.id" />
|
||||
<CronNotice v-else-if="turn.role === 'cron'" :text="turn.text" :cron="turn.cron" :turn-id="turn.id" :created-at="turn.createdAt" />
|
||||
|
||||
<!-- Assistant turn → left-aligned, no name/role label. -->
|
||||
<div v-else class="a-msg turn-anchor" :data-turn-id="turn.id">
|
||||
|
|
@ -660,7 +632,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
@open-agent="emit('openAgent', $event)"
|
||||
/>
|
||||
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" mobile :tool-diff-panel="toolDiffPanel" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" @open-tool-diff="emit('openToolDiff', $event)" @open-agent="emit('openAgent', $event)" />
|
||||
<CronNotice v-else-if="blk.kind === 'cron'" :text="blk.text" :cron="blk.cron" />
|
||||
</template>
|
||||
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && (assistantRunFinalText(ti).trim().length > 0 || turn.durationMs !== undefined)" class="a-msg-ft">
|
||||
<Tooltip :text="`${turn.durationMs} ms`">
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
<!-- apps/kimi-web/src/components/chat/CronNotice.vue -->
|
||||
<!-- In-transcript card for a turn triggered by a scheduled reminder rather
|
||||
than a real user. A left accent bar + soft tint make it read as one event
|
||||
card distinct from the chat around it; the header carries the glyph, title,
|
||||
a humanized schedule ("Every 5 minutes") and a dimmed job id; the fired
|
||||
prompt is collapsed to its first line with an expand toggle so long prompts
|
||||
don't swamp the transcript. stale/missed fires switch the accent to warning.
|
||||
<!-- In-transcript notice for a turn triggered by a scheduled reminder rather
|
||||
than a real user. It is styled to read like a user message — a right-
|
||||
aligned, max-width-capped bubble in the user-bubble colour — because a cron
|
||||
fire is semantically a message the user scheduled earlier. The bubble shows
|
||||
the title + the fired prompt in full, wrapping across lines (no truncation,
|
||||
no tooltip). Schedule / status / job id / fire time sit in a small meta row
|
||||
beneath the bubble, mirroring the meta row under a real user message; the
|
||||
fire time reuses the same <MessageTime> component as a user message so the
|
||||
two stay identical.
|
||||
|
||||
Renders either as a standalone turn (pass turnId for the scroll anchor) or
|
||||
embedded inside an assistant turn's blocks (a cron steered into an in-flight
|
||||
turn) — in both cases it takes the same text + cron data. -->
|
||||
embedded inside an assistant turn's blocks — in both cases it takes the
|
||||
same text + cron data. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from '../ui/Icon.vue';
|
||||
import { humanizeCron, collapsePrompt } from '../../lib/cronHumanize';
|
||||
import MessageTime from './MessageTime.vue';
|
||||
import { humanizeCron } from '../../lib/cronHumanize';
|
||||
import type { CronTurnData } from '../../types';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -22,13 +26,15 @@ const props = defineProps<{
|
|||
/** Scroll-anchor id for a standalone cron turn; omitted when embedded in an
|
||||
* assistant turn's blocks (the assistant turn already carries the anchor). */
|
||||
turnId?: string;
|
||||
/** ISO timestamp of when the cron fired (the turn's createdAt). Omitted for
|
||||
* the embedded-in-assistant case, which has no turn of its own. */
|
||||
createdAt?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const cron = computed(() => props.cron);
|
||||
const missed = computed(() => cron.value?.missedCount !== undefined);
|
||||
const warning = computed(() => cron.value?.stale === true || missed.value);
|
||||
|
||||
const title = computed(() =>
|
||||
missed.value ? t('conversation.cron.missed') : t('conversation.cron.fired'),
|
||||
|
|
@ -39,9 +45,12 @@ const schedule = computed(() => {
|
|||
return expr ? humanizeCron(expr, t) : '';
|
||||
});
|
||||
|
||||
// Status-only metadata: schedule + job id already live in the header, so this
|
||||
// line surfaces only the fire-state flags (one-shot / coalesced / missed /
|
||||
// final delivery) and is hidden when none apply.
|
||||
// A clean fire reads as "ok" (green ✓); a missed fire (skipped runs) as
|
||||
// "error" (red ✗). Surfaced in the meta row below the bubble.
|
||||
const statusKind = computed<'ok' | 'error'>(() => (missed.value ? 'error' : 'ok'));
|
||||
|
||||
// Fire-state flags (one-shot / coalesced / missed / final delivery); shown in
|
||||
// the meta row when any apply.
|
||||
const statusDetail = computed(() => {
|
||||
const c = cron.value;
|
||||
if (!c) return '';
|
||||
|
|
@ -57,109 +66,95 @@ const statusDetail = computed(() => {
|
|||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
const text = computed(() => props.text ?? '');
|
||||
const collapsed = computed(() => collapsePrompt(text.value));
|
||||
const hasMore = computed(() => collapsed.value.hasMore);
|
||||
const shownText = computed(() => (expanded.value ? text.value : collapsed.value.text));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cron-notice"
|
||||
:class="{ 'is-warning': warning, 'turn-anchor': !!turnId }"
|
||||
class="cn cron-notice"
|
||||
:class="{ 'turn-anchor': !!turnId }"
|
||||
:data-turn-id="turnId"
|
||||
role="status"
|
||||
>
|
||||
<div class="cn-head">
|
||||
<span class="cn-icon" aria-hidden="true"><Icon name="clock" size="sm" /></span>
|
||||
<div class="cn-bubble">
|
||||
<span class="cn-title">{{ title }}</span>
|
||||
<span v-if="schedule" class="cn-badge">{{ schedule }}</span>
|
||||
<template v-if="text"> <span class="cn-prompt">{{ text }}</span></template>
|
||||
</div>
|
||||
<div class="cn-meta">
|
||||
<Icon name="clock" size="sm" class="cn-meta-ico" aria-hidden="true" />
|
||||
<span v-if="schedule" class="cn-meta-item">{{ schedule }}</span>
|
||||
<span v-if="statusDetail" class="cn-meta-item">{{ statusDetail }}</span>
|
||||
<span class="cn-status" :class="statusKind" :aria-label="statusKind">
|
||||
<Icon v-if="statusKind === 'ok'" name="check" size="sm" />
|
||||
<Icon v-else name="close" size="sm" />
|
||||
</span>
|
||||
<span
|
||||
v-if="cron?.jobId"
|
||||
class="cn-job"
|
||||
class="cn-meta-item cn-id"
|
||||
:title="t('conversation.cron.job', { id: cron.jobId })"
|
||||
>{{ cron.jobId }}</span>
|
||||
<MessageTime v-if="createdAt" :time="createdAt" />
|
||||
</div>
|
||||
|
||||
<div v-if="statusDetail" class="cn-status">{{ statusDetail }}</div>
|
||||
|
||||
<div v-if="text" class="cn-prompt">{{ shownText }}</div>
|
||||
<button
|
||||
v-if="text && hasMore"
|
||||
type="button"
|
||||
class="cn-toggle"
|
||||
@click="expanded = !expanded"
|
||||
>{{ expanded ? t('conversation.cron.collapse') : t('conversation.cron.expand') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cron-notice {
|
||||
display: block;
|
||||
align-self: stretch;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font: var(--text-sm)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cron-notice.is-warning {
|
||||
border-left-color: var(--color-warning);
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
.cn-head {
|
||||
.cn {
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
max-width: 78%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.cn-icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.cron-notice.is-warning .cn-icon {
|
||||
color: var(--color-warning);
|
||||
|
||||
/* Mirrors the user bubble (.u-bub): accent fill + border, rounded with one
|
||||
small corner, soft shadow. The prompt is shown in full and wraps across
|
||||
lines (long tokens break) — no truncation. */
|
||||
.cn-bubble {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-accent-soft);
|
||||
border: 1px solid var(--color-accent-bd);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) var(--radius-sm) var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
color: var(--color-text);
|
||||
font-size: var(--content-font-size);
|
||||
line-height: var(--leading-normal);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cn-title {
|
||||
font-weight: 600;
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.cn-badge {
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-accent-bd);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
|
||||
/* Meta row under the bubble, sized to match the user message's meta row. */
|
||||
.cn-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
.cn-job {
|
||||
margin-left: auto;
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
.cn-meta-ico {
|
||||
flex: none;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.cn-meta-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cn-status {
|
||||
margin-top: 4px;
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.cn-prompt {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
.cn-status.ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.cn-toggle {
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cn-toggle:hover {
|
||||
text-decoration: underline;
|
||||
.cn-status.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
62
apps/kimi-web/src/components/chat/MessageTime.vue
Normal file
62
apps/kimi-web/src/components/chat/MessageTime.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<!-- apps/kimi-web/src/components/chat/MessageTime.vue -->
|
||||
<!-- Click-to-expand timestamp shown under a message bubble (a real user
|
||||
message or a cron-fired message). Collapsed: a compact form via
|
||||
formatMessageTime (today "HH:MM", yesterday "昨天 HH:MM", this year
|
||||
"MM-DD HH:MM", older "YYYY-MM-DD HH:MM"). Expanded on click: the full
|
||||
"YYYY-MM-DD HH:MM". One component so the time under a user message and a
|
||||
cron notice stays identical. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatMessageTime } from '../../lib/formatMessageTime';
|
||||
|
||||
const props = defineProps<{ time: string }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const expanded = ref(false);
|
||||
|
||||
const full = computed(() => {
|
||||
const d = new Date(props.time);
|
||||
if (Number.isNaN(d.getTime())) return props.time;
|
||||
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())}`;
|
||||
});
|
||||
|
||||
const display = computed(() =>
|
||||
expanded.value ? full.value : formatMessageTime(props.time, t('conversation.yesterday')),
|
||||
);
|
||||
|
||||
function toggle(): void {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="msg-time" @click.stop="toggle">{{ display }}</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.msg-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
box-sizing: border-box;
|
||||
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;
|
||||
}
|
||||
.msg-time:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-accent);
|
||||
background: var(--hover);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<TurnBlock, { kind: 'tool' }>): 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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <path>`; 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));
|
||||
|
|
|
|||
|
|
@ -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<IconName, IconEntry> = {
|
||||
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<readonly [string, readonly IconName[]]>
|
|||
'check-list',
|
||||
'bolt',
|
||||
'git-pull-request',
|
||||
'calendar-schedule',
|
||||
'calendar-todo',
|
||||
'calendar-close',
|
||||
],
|
||||
],
|
||||
['Communication', ['message', 'mail', 'user']],
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ const TOOL_GLYPH: Record<string, IconName> = {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
'<cron-fire jobId="j" cron="* * * * *" recurring="true" coalescedCount="1" stale="false">\n' +
|
||||
'<prompt>\nCheck BTC\n</prompt>\n</cron-fire>';
|
||||
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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue