kimi-code/apps/kimi-web/test/input-history.test.ts
qer b905dd4910
feat(web): redesign web UI and add design system (#1258)
* feat: redesign web ui & add design system

* feat(web): add motion to redesigned UI and add changesets

Animate toast enter/leave, dialog open, and workspace-list and tool-row expand/collapse instead of snapping, and add the changesets covering the web redesign.

* fix(web): remove undo message exit animation

* fix(web): route agent tools to AgentTool and focus dialog on open

- toolRegistry matched the raw 'agent' name, but normalizeToolName folds
  agent/subagent into 'task', so agent calls fell through to GenericTool
  and lost the inline Open button for the subagent detail panel.
- Dialog's focus watcher only fired on change; callers that mount with
  open already true (Login, Settings, ...) never moved focus into the
  modal. Run it immediately so initial focus and restore-on-close work.

* feat(web): add logo long-press design-system easter egg

Hold the sidebar logo for 3 seconds to open a dialog showing the design system page. Also trim and rebalance the redesign changesets.

* fix(web): silence Sidebar v-show warning by making it single-root

Nest the design-system Teleport inside the sidebar <aside> so the component has a single element root. App applies v-show to Sidebar, which needs an element root to attach to; the fragment root logged a "non-element root node" warning on every reactive update and the collapse did not take effect.

* fix(web): thinking toggle, tool-group i18n, agent detail button

- Show the default-thinking switch as on whenever thinking is effectively enabled (enabled !== false), matching the core resolver.

- Route the grouped tool-call header and status through vue-i18n.

- Hide the subagent "Open detail" button when no matching task exists (e.g. a completed foreground subagent after a refresh).

* fix(web): use strict equality in agent detail button guard

oxlint eqeqeq flagged the loose != null check; resolveAgentTaskId returns string | undefined, so compare with !== undefined.

* chore(web): remove stray design mockups and screenshots

Remove the design exploration mockups, screenshots, prompts, and notes that were accidentally committed under apps/kimi-web/design. Keep design-system.html, which the sidebar logo easter egg still references.

* fix(web): keep sessions on continuation failure, treat absent thinking as on

- Keep sessions already loaded from earlier pages when a continuation page fetch fails, instead of replacing the workspace with an empty page.

- Treat an absent thinking config as enabled in the settings toggle, matching the core resolver (thinking is on unless explicitly disabled).

* feat(web): open design-system easter egg on 10 logo clicks

Replace the 3-second long-press trigger with 10 consecutive clicks on the Kimi mark; the count resets after a short idle. The long-press was unreliable because pointerleave cancelled the timer on any drift.

* fix(web): treat cancelled swarm members as finished

phaseForTask now lets a terminal status (completed/failed/cancelled) override a stale subagentPhase, so a cancelled swarm member no longer stays live and suppresses the finished AgentSwarm card.

* feat(web): use 1s long-press for design-system easter egg

Switch the logo easter egg back to a long-press, shortened to 1 second, and make it reliable this time: use pointer capture plus touch-action:none so a slight drift no longer cancels the hold.

* fix(web): use plain Spinner for activity notices

ActivityNotice renders for non-chat loading states (e.g. compaction), so it must use Spinner per the design-system rule that reserves MoonSpinner for the chat first-response state.

* docs(web): add a11y guidance to design system

* docs(web): drop stale design README link

* fix(web): restore model search focus and define panel header weight

- Bind the model picker's search Input to searchRef so useDialogFocus moves focus into it on open instead of the dialog's close button.

- Use the defined --weight-semibold token for panel header titles (--weight-bold is not declared, so the shorthand was invalid).

* fix(web): let any open dialog own Escape over the side panel

Track open design-system Dialog instances in a shared count and include it in App.vue's anyOverlayOpen, so a dialog whose open state lives outside App.vue (such as the sidebar session search) captures Escape before the background side panel closes.

* fix(web): base dock-work flag on filtered dock task lists

Foreground subagents are excluded from the dock task lists, so a session whose only task is a foreground subagent no longer renders an empty workbar above the composer.

* fix(web): create subagent task before forwarding text deltas

A client that subscribes from a snapshot after subagent.spawned already fired never received the lifecycle taskCreated; the reducer only applies taskProgress to existing tasks, so assistant text deltas were dropped and the live subagent detail stayed blank. Emit taskCreated (via patchSubagent) before the text progress, mirroring the tool-progress path.

* fix(web): keep plan, swarm, and goal mode toggles per session

Plan, swarm, and goal modes were stored as global scalars on the web client and a single localStorage key each, so they leaked across sessions. Bind them to the active session via per-session maps, persist per session, and apply server status/events to the originating session so background sessions keep independent state.

* fix(web): keep subagent detail reachable for synthesized tasks

When the web client subscribes after a subagent already spawned, the synthesized subagent task has no parentToolCallId, so the Agent tool's Open-detail button was hidden and the panel would not open. Fall back to the single unmapped subagent task when resolving the detail target in both the button visibility and the panel open paths.

* fix(web): keep session kebab menu from being clipped

Teleport the SessionRow kebab menu to body and anchor it with fixed positioning so the collapsing group-sessions list's overflow:hidden no longer clips the dropdown.

* fix(web): apply staged modes to the created session by id

When starting the first prompt, apply the staged plan/swarm/goal modes to the just-created session's per-session maps by id instead of via the activeSessionId-based setters, so a session switch during the selectSession await can't drop the modes for this session or pollute another.

* refactor(web): unify confirmation dialogs into a single modal

Replace the inconsistent confirmation patterns (native confirm(),
two-step menu arming, hand-rolled inline strips, bare buttons) with one
modal ConfirmDialog driven by a global useConfirmDialog() composable,
and consolidate the duplicated confirm/cancel i18n keys.

* feat(web): inline message queue with separate stop button

- Send button always sends/enqueues; interrupt moves to a separate red Stop
  button shown only while running, so the two can no longer be confused.
- Queued prompts now render inline at the tail of the transcript (after the
  running turn) instead of behind the dock panel: click to edit, remove, drag
  the grip to reorder, with image thumbnails and a "next up" marker.
- Remove the dock queue panel and the QueuePane component; Steer stays on
  Ctrl/Cmd+S.

* chore: add changeset for web queue UX

* feat(web): prompt reliability, sidebar menu, and composer/markdown polish

- Fix spurious errors when question/approval/task actions were already complete
- Add loading feedback to question and approval prompts; block double-clicks
- Make the question "Other" option selectable by row click and let Enter advance/submit
- Consolidate workspace section actions into an overflow menu
- Tighten markdown prose line-height and block spacing
- Recall input history only when the caret is at text start
2026-07-01 20:47:12 +08:00

265 lines
8.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ref, type Ref } from 'vue';
import { useInputHistory } from '../src/composables/useInputHistory';
import { STORAGE_KEYS } from '../src/lib/storage';
interface MockTextarea {
value: string;
selectionStart: number;
selectionEnd: number;
setSelectionRange: (start: number, end: number) => void;
}
function setup(initialText = '', caret = 0, sessionId: string | null = 'test-session') {
const textarea: MockTextarea = {
value: initialText,
selectionStart: caret,
selectionEnd: caret,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
};
const text = ref(initialText);
const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref<HTMLTextAreaElement | null>;
const history = useInputHistory({ text, textareaRef, autosize: () => {}, sessionId: () => sessionId ?? undefined });
return { text, textarea, history };
}
describe('useInputHistory — push', () => {
it('ignores empty or whitespace-only entries', () => {
const { history } = setup();
history.push('');
history.push(' ');
expect(history.hasHistory()).toBe(false);
});
it('appends distinct entries newest-last', () => {
const { history } = setup();
history.push('a');
history.push('b');
history.push('c');
expect(history.hasHistory()).toBe(true);
});
it('skips a consecutive duplicate', () => {
const { text, history } = setup();
history.push('a');
history.push('a'); // duplicate of the newest entry — must be dropped
history.push('b');
history.recallOlder(); // -> b
expect(text.value).toBe('b');
history.recallOlder(); // -> a (only one 'a' was kept)
expect(text.value).toBe('a');
history.recallOlder(); // already oldest — must stay, not land on a second 'a'
expect(text.value).toBe('a');
});
it('drops entries pushed without a session (draft / empty composer)', () => {
const { history } = setup('', 0, null);
history.push('hello');
expect(history.hasHistory()).toBe(false);
});
});
describe('useInputHistory — recall', () => {
it('walks backward from the most recent entry, then restores the live draft', () => {
const { text, history } = setup('draft');
history.push('a');
history.push('b');
history.push('c');
expect(history.isBrowsing()).toBe(false);
history.recallOlder(); // -> c
expect(text.value).toBe('c');
expect(history.isBrowsing()).toBe(true);
history.recallOlder(); // -> b
expect(text.value).toBe('b');
history.recallOlder(); // -> a
expect(text.value).toBe('a');
history.recallOlder(); // already oldest, stay
expect(text.value).toBe('a');
history.recallNewer(); // -> b
expect(text.value).toBe('b');
history.recallNewer(); // -> c
expect(text.value).toBe('c');
history.recallNewer(); // -> back to the live draft
expect(text.value).toBe('draft');
expect(history.isBrowsing()).toBe(false);
});
it('restores an empty live draft after recalling the single newest entry', () => {
const { text, history } = setup('');
history.push('only');
history.recallOlder();
expect(text.value).toBe('only');
history.recallNewer();
expect(text.value).toBe('');
});
it('does nothing when recalling with an empty history', () => {
const { text, history } = setup('draft');
history.recallOlder();
history.recallNewer();
expect(text.value).toBe('draft');
expect(history.isBrowsing()).toBe(false);
});
it('resetBrowsing drops out of history mode without changing text', () => {
const { text, history } = setup('draft');
history.push('a');
history.recallOlder();
expect(history.isBrowsing()).toBe(true);
history.resetBrowsing();
expect(history.isBrowsing()).toBe(false);
expect(text.value).toBe('a'); // the recalled entry stays as the editable text
});
});
describe('useInputHistory — caretAtTextStart', () => {
it('is true at the very start of the text', () => {
const { textarea, history } = setup('hello\nworld', 0);
textarea.value = 'hello\nworld';
expect(history.caretAtTextStart()).toBe(true);
});
it('is false when the caret is on the first line but not at the start', () => {
const { textarea, history } = setup('hello\nworld', 3);
textarea.value = 'hello\nworld';
expect(history.caretAtTextStart()).toBe(false);
});
it('is false once the caret is past the first newline', () => {
const { textarea, history } = setup('hello\nworld', 8);
textarea.value = 'hello\nworld';
expect(history.caretAtTextStart()).toBe(false);
});
it('is true for an empty composer', () => {
const { history } = setup('', 0);
expect(history.caretAtTextStart()).toBe(true);
});
});
function memoryStorage(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
clear: () => {
map.clear();
},
getItem: (key: string) => map.get(key) ?? null,
key: (index: number) => Array.from(map.keys())[index] ?? null,
removeItem: (key: string) => {
map.delete(key);
},
setItem: (key: string, value: string) => {
map.set(key, value);
},
};
}
describe('useInputHistory — persistence', () => {
let original: Storage | undefined;
beforeEach(() => {
original = (globalThis as { localStorage?: Storage }).localStorage;
Object.defineProperty(globalThis, 'localStorage', {
value: memoryStorage(),
configurable: true,
writable: true,
});
});
afterEach(() => {
if (original === undefined) {
delete (globalThis as { localStorage?: Storage }).localStorage;
} else {
Object.defineProperty(globalThis, 'localStorage', {
value: original,
configurable: true,
writable: true,
});
}
});
it('writes each pushed entry to localStorage under its session', () => {
const { history } = setup();
history.push('hello');
const stored = globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory);
expect(stored).toBe(JSON.stringify({ 'test-session': ['hello'] }));
});
it('a freshly mounted composable reads back the persisted history', () => {
const first = setup();
first.history.push('a');
first.history.push('b');
// Simulates the empty composer unmounting and the docked composer mounting.
const second = setup();
second.history.recallOlder();
expect(second.text.value).toBe('b');
second.history.recallOlder();
expect(second.text.value).toBe('a');
});
it('keeps histories of different sessions isolated', () => {
const a = setup('', 0, 'sess-a');
a.history.push('from-a');
const b = setup('', 0, 'sess-b');
b.history.push('from-b');
// Re-mount each session and confirm each only recalls its own entry.
const a2 = setup('', 0, 'sess-a');
a2.history.recallOlder();
expect(a2.text.value).toBe('from-a');
a2.history.recallOlder(); // no older entry — must stay
expect(a2.text.value).toBe('from-a');
const b2 = setup('', 0, 'sess-b');
b2.history.recallOlder();
expect(b2.text.value).toBe('from-b');
});
it('trims to the newest 100 entries, dropping the oldest', () => {
const { text, history } = setup();
for (let i = 0; i < 105; i++) history.push(`m${i}`);
// Walk all the way back; the oldest kept entry must be m5 (m0..m4 dropped).
for (let i = 0; i < 100; i++) history.recallOlder();
expect(text.value).toBe('m5');
history.recallOlder(); // already at the oldest kept entry — must not move
expect(text.value).toBe('m5');
});
it('ignores a malformed stored value and starts empty', () => {
globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, 'not-json');
const { history } = setup();
expect(history.hasHistory()).toBe(false);
});
it('migrates a legacy global array into the current session once', () => {
globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1', 'old2']));
const { text, history } = setup('', 0, 'sess-x');
history.recallOlder(); // -> old2
expect(text.value).toBe('old2');
history.recallOlder(); // -> old1
expect(text.value).toBe('old1');
// Persisted in the new map format under the current session.
const stored = JSON.parse(globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory)!);
expect(stored).toEqual({ 'sess-x': ['old1', 'old2'] });
});
it('leaves the legacy array untouched when mounted without a session', () => {
globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1']));
const { history } = setup('', 0, null);
expect(history.hasHistory()).toBe(false);
// A later docked mount (with a session id) can still migrate it.
const { text, history: docked } = setup('', 0, 'sess-y');
docked.recallOlder();
expect(text.value).toBe('old1');
});
});