diff --git a/docs/design/2026-11-01-web-shell-waiting-minigame.md b/docs/design/2026-11-01-web-shell-waiting-minigame.md new file mode 100644 index 00000000000..981ac2823e1 --- /dev/null +++ b/docs/design/2026-11-01-web-shell-waiting-minigame.md @@ -0,0 +1,219 @@ +# Web Shell waiting card + +[English](2026-11-01-web-shell-waiting-minigame.md) | [简体中文](2026-11-01-web-shell-waiting-minigame.zh-CN.md) + +Status: implemented; runtime release verification pending. + +## Problem + +A streaming prompt can take minutes. The composer only shows a spinner, +elapsed time, token count, and a rotating loading phrase. Users have nothing +playful or interactive to do while they wait, and the Web Shell has no waiting +experience beyond passive status text. + +## Current state + +`StreamingStatus` renders above the main `ChatEditor` whenever +`streamingState !== 'idle'` or the daemon reports an active prompt. The Web +Shell owns the streaming state, transcript messages, token usage, composer +layout, i18n, theme tokens, and local storage patterns. Split panes and +side-task panes render their own `ChatPane` composers and keep a compact status +line. + +## Goals + +- Add a compact heatmap button at the end of the main composer's streaming + status while a prompt is running. +- Open a waiting card above the status row: + - the task's Q&A activity heatmap by default, with hover details and + click-to-locate; + - an `Activity` / `Usage` switch, where `Usage` shows task token consumption + instead of a credits balance; + - an optional, configuration-gated game entry on a random cold day. +- Keep the activity and usage views available without configuration; the game + entry stays hidden unless it is explicitly enabled. +- Keep keyboard input and the composer usable; never hijack typing. +- Keep the feature local to the browser: no backend, daemon, transcript, + telemetry, or model changes. + +## Non-goals + +- No welcome-page game, leaderboard, multiplayer, or cross-device scores. +- No game in split panes, side-task panes, or on coarse-pointer devices. +- No mobile touch controls in this change. + +## Proposed solution + +`WaitingMinigameButton` renders at the end of the `StreamingStatus` row while a +prompt is active. Clicking it mounts `WaitingMinigame` above the row: + +1. The card opens on `Activity`: a fixed 44-week calendar (44 columns × 7 + rows). Columns are Monday-to-Sunday weeks and every cell is one local day. + The calendar ends with the current week; days after today are empty + placeholders. Cell color shows that day's Q&A count, month labels sit below + the grid, hovering a cell shows its date and count, and clicking a populated + day scrolls the transcript to the first Q&A of that day and highlights the + message. +2. When the game entry is enabled, a white square with a dark gamepad icon + appears after five seconds on a randomly chosen cold day. Clicking it starts + either horizontal Tetris or Snake at random; there is no picker or switch + control. Without the configuration, the gamepad mark and all game controls stay + absent and the card remains an activity/usage readout. +3. The `Usage` tab shows total tokens, input, output, cached, and context + used/limit for the task. It replaces the credits view from the reference + design. +4. A `QWEN CODE` pixel intro plays before the game. For Tetris, the letters + appear cell by cell, then the blocks fly to the right edge and settle into + the random start cluster that the game begins with. For Snake, the letters + stay still and the game starts without moving blocks. Both games use the + same 44×7 board. Tetris clears columns for 50 points; Snake eats an apple + emoji and scores one point per apple. Both pause with Space and close with + Escape. +5. When the prompt ends, the button and card unmount. Best scores and the sound + preference stay in `localStorage`. +6. Inputs, textareas, contenteditable editors, and textboxes keep their + keyboard behavior. The card ignores keys targeted at those elements. + +The activity and token summaries live in framework-free +`utils/waitingActivity.ts`; the two games live in `utils/waitingMinigame.ts` +and `utils/waitingSnake.ts`, so engines can be tested without React, jsdom, or +timers. + +## Design decisions + +- **Inline trigger instead of a banner.** A full-width strip competed with the + conversation. A single icon at the end of the status line costs no extra row; + the card only appears on demand. +- **Activity first.** The expanded card starts with the task's own daily + calendar rather than the game. It gives the card a useful reading state and + makes the gamepad mark an intentional, optional game entry. +- **Activity primary, game opt-in.** The card's product purpose is to surface + the task's Q&A activity by time; the game is a waiting-time extra. Keeping + the game behind configuration avoids shipping an unexpected interactive + surface to every user. +- **Hover details and jump-to-message.** Each bucket exposes its time range and + Q&A count, and a bucket that contains messages becomes a navigation target. + The anchor prefers the first user question so the transcript lands at the + start of the exchange. +- **Delayed entry on a cold cell.** Hiding the gamepad mark for five seconds keeps + the heatmap useful during the first moments of a turn. Placing it on the + coldest day keeps the busiest dates unobstructed. +- **Fixed daily cells.** Every cell is one calendar day instead of a bucket + stretched from the task span. Time semantics stay stable, a short task simply + lights up today's cell, and the month axis matches the reference calendar. +- **Random game instead of a picker.** A single click starts one of the two + games; removing the picker and switch keeps the waiting card compact. +- **Different intros for the two games.** Tetris uses the intro blocks as its + starting pile, so the blocks fly to the right edge. Snake has no pile to + inherit, so its intro stays still and yields directly to the game. +- **Tokens instead of credits.** The task already owns token and context data; + a credits balance would introduce a second accounting surface. +- **Flow layout instead of an overlay.** Adding a normal footer child avoids + DOM measurement, clipping, and z-index coupling with the editor. The + trade-off is a transient transcript resize when opening the card. +- **Stable card frame.** The header, tab row, body and footer are reserved in + every view, and the body keeps the calendar height. Switching between + activity, usage, intro and game does not resize the card or move the + composer. +- **Main composer only.** The App composer is the single-session surface. + Split and side-task panes are separate `ChatPane` instances and do not + render the card. +- **Coarse pointer hidden.** `isCoarsePointerDevice()` keeps the card out of + phones and tablets; a narrow desktop window still gets the compact grid. +- **Immutable engines.** Each tick returns a new state object. React state + updates stay predictable, and render grids can be derived without mutating + boards. +- **Best-effort audio.** A Web Audio context is created inside the start + gesture. Missing or blocked audio never blocks a game. + +## Constraints + +- No new dependencies. +- No changes to daemon routes, prompts, transcript blocks, or host props. +- Approval and question overlays render above the composer. The card stays + inside the composer footer below them. + +## Configuration + +The activity and usage views are always available. The game entry is off by +default and can be enabled with any of: + +- `?waitingGames=1` on the Web Shell URL; `?waitingGames=0` forces it off even + when a stored or host flag enables it. +- `localStorage['qwen-code-web-shell-waiting-games'] = '1'`. +- A host-injected `window.__QWEN_CODE_WAITING_GAMES__ = true`. + +The local harness exposes the same switch as `Games: off/on`. Game screenshots +in this document were captured with the entry enabled. + +## Screenshots + +The activity, usage and dark-theme screenshots use the local harness sample +data with the game entry off. The intro and game frames use the Chromium E2E +(`client/e2e/waiting-minigame.spec.ts`) with the game entry enabled. + +| Activity heatmap with hover details | QWEN CODE intro | +| ------------------------------------------------------------------ | ----------------------------------------------------- | +| ![Activity](waiting-minigame/waiting-minigame-heatmap-tooltip.png) | ![Intro](waiting-minigame/waiting-minigame-intro.png) | + +| Blocks flying to the right cluster | Settled start cluster | +| ------------------------------------------------------- | ------------------------------------------------------- | +| ![Flight](waiting-minigame/waiting-minigame-flight.png) | ![Landed](waiting-minigame/waiting-minigame-landed.png) | + +| Tetris | Snake with an apple | +| ------------------------------------------------------ | ----------------------------------------------------- | +| ![Tetris](waiting-minigame/waiting-minigame-board.png) | ![Snake](waiting-minigame/waiting-minigame-snake.png) | + +| Token usage (sample) | Dark theme, Chinese heatmap | +| ------------------------------------------------------ | ---------------------------------------------------------------------- | +| ![Usage](waiting-minigame/waiting-minigame-tokens.png) | ![Dark Chinese](waiting-minigame/waiting-minigame-dark-zh-heatmap.png) | + +When the game entry is configured, a gamepad icon appears on a cold day: + +![Game entry](waiting-minigame/waiting-minigame-game-entry.png) + +## Validation plan + +- Activity/token tests: fixed calendar, day grouping, future placeholders, + message anchors, usage sums and fallback. +- Tetris tests: spawn, gravity, movement bounds, rotation kicks, locking, + column clears, score, speed floor, game over, pause, render grid, and the + prefilled start cluster. +- Snake tests: spawn, movement, turn queue and reversals, food and score, wall + and body collision, pause/restart, render grid, and the slower opening speed. +- Component tests: closed by default, activity first, day hover details, day + click, hidden game entry by default, configured game entry after five + seconds, intro timings, Tetris flight and landing, static Snake intro, + pause/resume, Escape, tab-hidden pause, trigger state. +- Configuration tests: default off, storage flag, URL flag and override, + host-injected global. +- App regression suite (`client/App.test.tsx`) for the composer and streaming + status. +- Chromium E2E: trigger → heatmap → hover details → game-off default → + configured game entry → QWEN CODE intro → Tetris flight/landing → pause; + Snake intro without movement; dark theme and Chinese copy. +- Local harness: `client/e2e/waiting-minigame-harness.html` with synthetic + activity/token data, selectable entry delay, and theme/language switches. + +## Acceptance criteria + +- The trigger appears only while a prompt is active; the card closes with the + turn. +- The card opens on a fixed 44-week daily calendar, shows day details on + hover, and locates a clicked day in the transcript. +- The gamepad mark appears after five seconds on a cold day when the game entry + is configured; both games run in the same 44×7 board. +- The game entry and game controls are hidden by default and only appear with + the configuration enabled. +- The Tetris intro blocks form the right-side start cluster; the Snake intro + does not move its blocks. +- The game/card does not break composer input and never steals editor keys. +- Split panes, side-task panes, and coarse-pointer devices do not show the + card. +- `localStorage` keeps the best scores and sound preference. + +## Open questions + +- Should a later change expose the card on the welcome page or on touch + devices with on-screen controls? +- Should the host be able to disable the card through customization? diff --git a/docs/design/2026-11-01-web-shell-waiting-minigame.zh-CN.md b/docs/design/2026-11-01-web-shell-waiting-minigame.zh-CN.md new file mode 100644 index 00000000000..7b557058c1f --- /dev/null +++ b/docs/design/2026-11-01-web-shell-waiting-minigame.zh-CN.md @@ -0,0 +1,128 @@ +# Web Shell 等待卡片 + +[English](2026-11-01-web-shell-waiting-minigame.md) | [简体中文](2026-11-01-web-shell-waiting-minigame.zh-CN.md) + +状态:已实现;运行时发布验收待完成。 + +## 问题 + +一次流式提问可能持续数分钟。输入框上只显示转圈、耗时、token 数和轮换的加载文案,等待期间没有可交互的内容。Web Shell 目前只有被动的等待状态。 + +## 现状 + +`StreamingStatus` 在 `streamingState !== 'idle'` 或 daemon 报告存在活动 prompt 时显示在主 `ChatEditor` 上方。Web Shell 已掌握真实流式态、转录消息、Token 用量、输入框布局、i18n、主题 token 和本地存储模式。分屏和侧边任务使用各自的 `ChatPane` 输入框,并保留紧凑状态行。 + +## 目标 + +- 提问运行期间,在主输入框的流式状态行末尾显示一个紧凑的热力图按钮。 +- 点击后在状态行上方打开等待卡片: + - 默认显示本任务的按天问答活动日历,支持悬停详情和点击定位; + - 可在 `热力图` / `消耗` 之间切换,`消耗` 显示本任务 Token,而不是 Credits 余额; + - 游戏入口是可配置的可选项,默认不显示。 +- 活动和 Token 视图无需配置即可使用;只有显式开启配置后才展示游戏入口。 +- 保持输入框和键盘可用,绝不抢占用户输入。 +- 功能只发生在浏览器本地:不写后端、daemon、转录、埋点或模型请求。 + +## 非目标 + +- 不做欢迎页小游戏、排行榜、多人对战或跨设备分数。 +- 分屏 pane、侧边任务 pane 和粗指针设备不展示卡片。 +- 本次不做手机触控操作。 + +## 方案 + +`WaitingMinigameButton` 在 prompt 活动期间渲染于 `StreamingStatus` 状态行末尾。点击后,`WaitingMinigame` 卡片挂载到状态行上方: + +1. 卡片默认打开 `热力图`:固定 44 周日历(44 列 × 7 行)。列是周一到周日的自然周,每个方格是一天;日历结束于当前周,今天之后的格子留空。颜色表示当天的问答数量,月份标签在日历下方;悬停方格显示日期和数量,点击有问答的日期会滚动到当天第一条问答并高亮消息。 +2. 配置开启游戏入口后,白底深色游戏手柄图标的方块会在 5 秒后出现在随机一个冷门日期上。点击后随机开始横向俄罗斯方块或贪吃蛇,不提供游戏选择或换游戏。未开启配置时游戏手柄图标和所有游戏控件都不显示,卡片只展示活动和 Token。 +3. `消耗` 页显示本任务总 Token、输入、输出、缓存以及上下文占用/上限,替代参考设计里的 Credits 视图。 +4. 游戏开始前播放 `QWEN CODE` 像素入场。俄罗斯方块会把文字方块飞到右侧,落成随机开局堆;贪吃蛇没有开局堆,文字保持不动后直接进入游戏。两个游戏共用 44×7 棋盘。俄罗斯方块满列 +50;贪吃蛇吃苹果 emoji,每个苹果 +1;两者都支持空格暂停、Esc 关闭。 +5. 回复结束时按钮和卡片一起卸载。最高分和音效偏好保存在 `localStorage`。 +6. 输入框、文本域、contenteditable 编辑器和 textbox 保留原有键盘行为;卡片忽略发往这些元素的按键。 + +活动与 Token 摘要放在与框架无关的 `utils/waitingActivity.ts`;两个游戏分别放在 `utils/waitingMinigame.ts` 和 `utils/waitingSnake.ts`,引擎测试不依赖 React、jsdom 或计时器。 + +## 设计决策 + +- **使用行内按钮而非横幅。** 输入框上方的整条横幅会和对话内容争夺垂直空间;状态行末尾的单个图标按钮不额外占一行,卡片只在需要时出现。 +- **热力图优先。** 展开卡片先展示本任务的按天活动日历,而不是直接开始游戏。它给卡片一个有用的阅读状态,也让游戏手柄图标成为明确的、可选的游戏入口。 +- **活动为主,游戏可配置。** 卡片的产品目的是透出任务按时间的问答热度;游戏只是等待时的附加项。把游戏放在配置后面,避免默认向所有用户暴露一个意外的交互面。 +- **悬停详情与点击定位。** 每个时间桶展示时间范围和问答条数;包含消息的桶可以点击定位。锚点优先选择第一条用户提问,让对话滚动到问答开始的位置。 +- **延迟出现并放在冷门格子。** 游戏手柄图标前 5 秒不出现,让热力图在回合刚开始时保持干净;放在最冷门的日期上可以避免遮挡最活跃的格子。 +- **固定日历格。** 每个方格固定代表一个自然日,而不是按任务时长拉伸出的时间桶。时间语义稳定,短任务只会点亮今天的格子,月份轴也和参考日历一致。 +- **随机游戏替代游戏选择。** 单击直接随机开始两个游戏之一;去掉选择器和换游戏入口后,等待卡片更紧凑。 +- **两个游戏使用不同入场。** 俄罗斯方块的入场方块会变成开局堆,所以方块飞到右侧落定;贪吃蛇没有可继承的开局堆,文字保持静止后直接进入游戏。 +- **Token 替代 Credits。** 任务本身已有 Token 和上下文数据;Credits 余额会引入第二套计量口径。 +- **使用流式布局而非覆盖层。** Web Shell 自己管理输入框与对话区的分栏;把卡片作为普通 footer 子元素可以避免 DOM 测量、裁切和编辑器 z-index 耦合。代价是展开卡片时对话区会出现一次短暂尺寸变化。 +- **固定卡片框架。** 每个视图都保留头部、标签行、主体和底部行,主体保持日历高度。在活动、消耗、入场和游戏之间切换时,卡片不会改变尺寸,输入框也不会移动。 +- **只做主输入框。** App 输入框代表单会话主界面。分屏和侧边任务使用独立 `ChatPane`,本次不渲染卡片。 +- **粗指针隐藏。** `isCoarsePointerDevice()` 让手机和平板不展示;窄桌面窗口仍可使用紧凑网格。 +- **不可变引擎。** 每次 tick 返回新状态,React 更新可预测,渲染网格可由棋盘推导,不需要原地修改。 +- **音效尽力而为。** Web Audio 上下文在开始手势内创建;音频缺失或被拦截不能阻塞游戏。 + +## 约束 + +- 不新增依赖。 +- 不修改 daemon 路由、prompt、转录块或宿主 props。 +- 授权或提问浮层位于输入框上方,卡片仍在输入框 footer 内、位于这些浮层下方。 + +## 配置 + +活动和消耗视图始终可用。游戏入口默认关闭,可通过以下任一方式开启: + +- Web Shell URL 增加 `?waitingGames=1`;`?waitingGames=0` 会强制关闭,即使本地存储或宿主开关已开启。 +- `localStorage['qwen-code-web-shell-waiting-games'] = '1'`。 +- 宿主注入 `window.__QWEN_CODE_WAITING_GAMES__ = true`。 + +本地 harness 使用 `Games: off/on` 开关模拟同一配置。本文档中的游戏截图均在开启游戏入口后截取。 + +## 页面截图 + +活动、消耗和深色主题截图使用本地 harness 示例数据,并关闭游戏入口;入场和游戏截图来自 Chromium E2E(`client/e2e/waiting-minigame.spec.ts`),且已开启游戏入口。 + +| 活动热力图(悬停详情) | QWEN CODE 入场 | +| -------------------------------------------------------------------- | ---------------------------------------------------- | +| ![活动热力图](waiting-minigame/waiting-minigame-heatmap-tooltip.png) | ![入场](waiting-minigame/waiting-minigame-intro.png) | + +| 方块飞向右侧堆 | 落定后的随机开局堆 | +| ----------------------------------------------------- | ----------------------------------------------------- | +| ![飞行](waiting-minigame/waiting-minigame-flight.png) | ![落定](waiting-minigame/waiting-minigame-landed.png) | + +| 俄罗斯方块 | 吃苹果的贪吃蛇 | +| ---------------------------------------------------------- | ------------------------------------------------------ | +| ![俄罗斯方块](waiting-minigame/waiting-minigame-board.png) | ![贪吃蛇](waiting-minigame/waiting-minigame-snake.png) | + +| Token 消耗(示例) | 深色主题 / 中文热力图 | +| ----------------------------------------------------------- | ------------------------------------------------------------------ | +| ![Token 消耗](waiting-minigame/waiting-minigame-tokens.png) | ![深色中文](waiting-minigame/waiting-minigame-dark-zh-heatmap.png) | + +配置开启游戏入口后,冷门日期上会显示游戏手柄图标: + +![游戏入口](waiting-minigame/waiting-minigame-game-entry.png) + +## 验证计划 + +- 活动/Token 单测:固定日历、按天归组、未来日期占位、消息锚点、Token 汇总与兜底。 +- 俄罗斯方块单测:生成、重力、移动边界、旋转踢墙、落定、满列消除、计分、速度下限、游戏结束、暂停、渲染网格、预置开局堆。 +- 贪吃蛇单测:生成、移动、转向队列与反向拦截、食物与计分、撞墙和撞身体、暂停/重开、渲染网格、更慢的初始速度。 +- 组件测试:默认关闭、优先显示热力图、日期悬停详情、日期点击、游戏入口默认隐藏、配置开启后 5 秒出现、入场时序、俄罗斯方块飞行与落定、贪吃蛇静止入场、暂停/继续、Esc、切后台暂停、按钮状态。 +- 配置测试:默认关闭、本地存储开关、URL 开关与覆盖、宿主注入全局变量。 +- App 回归套件(`client/App.test.tsx`)证明输入框和流式状态仍正常渲染。 +- Chromium E2E:按钮 → 热力图 → 悬停详情 → 默认无游戏入口 → 配置后游戏入口 → QWEN CODE 入场 → 俄罗斯方块飞行/落定 → 暂停;贪吃蛇不移动的入场;深色主题和中文文案。 +- 本地 harness:`client/e2e/waiting-minigame-harness.html`,带合成活动/Token 数据、可选入口延迟和主题/语言切换。 + +## 验收标准 + +- 按钮只在 prompt 活动期间出现;回复结束时卡片随之关闭。 +- 卡片默认打开固定 44 周日历,悬停显示当天详情,点击后定位到对应问答。 +- 游戏手柄图标在配置开启后 5 秒出现在冷门日期上,点击随机开始游戏;两个游戏都在同一 44×7 棋盘运行。 +- 游戏入口和游戏控件默认隐藏,只有配置开启后才出现。 +- 俄罗斯方块入场方块会落成右侧开局堆;贪吃蛇入场不移动方块。 +- 卡片不破坏输入框输入,编辑器内按键不被游戏抢占。 +- 分屏、侧边任务和粗指针设备不显示卡片。 +- `localStorage` 保留最高分和音效偏好。 + +## 待确认 + +- 后续是否在欢迎页或触屏设备上增加入口和屏幕按键? +- 是否需要给宿主提供关闭开关? diff --git a/docs/design/waiting-minigame/waiting-minigame-board.png b/docs/design/waiting-minigame/waiting-minigame-board.png new file mode 100644 index 00000000000..683a327d490 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-board.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-dark-zh-card.png b/docs/design/waiting-minigame/waiting-minigame-dark-zh-card.png new file mode 100644 index 00000000000..9cafc7fc413 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-dark-zh-card.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-dark-zh-heatmap.png b/docs/design/waiting-minigame/waiting-minigame-dark-zh-heatmap.png new file mode 100644 index 00000000000..f19068a6f62 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-dark-zh-heatmap.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-dark-zh.png b/docs/design/waiting-minigame/waiting-minigame-dark-zh.png new file mode 100644 index 00000000000..d80179bfbf5 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-dark-zh.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-flight.png b/docs/design/waiting-minigame/waiting-minigame-flight.png new file mode 100644 index 00000000000..41340792d18 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-flight.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-game-entry.png b/docs/design/waiting-minigame/waiting-minigame-game-entry.png new file mode 100644 index 00000000000..2fc2eed9b19 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-game-entry.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-heatmap-tooltip.png b/docs/design/waiting-minigame/waiting-minigame-heatmap-tooltip.png new file mode 100644 index 00000000000..eee8a4cf520 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-heatmap-tooltip.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-intro.png b/docs/design/waiting-minigame/waiting-minigame-intro.png new file mode 100644 index 00000000000..02ea80de86d Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-intro.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-landed.png b/docs/design/waiting-minigame/waiting-minigame-landed.png new file mode 100644 index 00000000000..c4f42db6690 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-landed.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-paused.png b/docs/design/waiting-minigame/waiting-minigame-paused.png new file mode 100644 index 00000000000..9e4c0846c24 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-paused.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-snake-intro.png b/docs/design/waiting-minigame/waiting-minigame-snake-intro.png new file mode 100644 index 00000000000..b76ac1a4c64 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-snake-intro.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-snake.png b/docs/design/waiting-minigame/waiting-minigame-snake.png new file mode 100644 index 00000000000..be155710072 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-snake.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-tokens.png b/docs/design/waiting-minigame/waiting-minigame-tokens.png new file mode 100644 index 00000000000..a9be9044dc7 Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-tokens.png differ diff --git a/docs/design/waiting-minigame/waiting-minigame-trigger.png b/docs/design/waiting-minigame/waiting-minigame-trigger.png new file mode 100644 index 00000000000..8c187a13bae Binary files /dev/null and b/docs/design/waiting-minigame/waiting-minigame-trigger.png differ diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 4b9f471adca..472a081d70a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -126,6 +126,10 @@ import { type SplitSessionOutcome, } from './utils/standalone-session-routing'; import { setBoundedMapEntry } from './utils/bounded-map'; +import { + buildWaitingActivity, + buildWaitingTokenUsage, +} from './utils/waitingActivity'; import { type SessionGitIntent } from './components/GitModePopover'; import { gitModeIntentMustReset } from './utils/gitModeIntent'; import { LocalControlQrButton } from './components/LocalControlQrButton'; @@ -211,6 +215,11 @@ import { SessionRecoveryBanner } from './components/SessionRecoveryBanner'; import composerStatusStyles from './components/ComposerStatusStack.module.css'; import { GoalEditDialog } from './components/dialogs/GoalEditDialog'; import { StreamingStatus } from './components/StreamingStatus'; +import { + WaitingMinigame, + WaitingMinigameButton, +} from './components/WaitingMinigame'; +import { readWaitingGamesEnabled } from './utils/waitingGamesConfig'; import { ToastHost, TOAST_REQUEST_EVENT, @@ -4845,6 +4854,25 @@ export function App({ }, []); const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary); + const waitingMinigameActivity = useMemo( + () => buildWaitingActivity(messages), + [messages], + ); + const waitingMinigameTokens = useMemo( + () => + buildWaitingTokenUsage( + messages, + connection.tokenUsage ?? {}, + connection.tokenCount, + connection.contextWindow, + ), + [ + messages, + connection.tokenUsage, + connection.tokenCount, + connection.contextWindow, + ], + ); const messagesRef = useRef(messages); const [failedPrompt, setFailedPrompt] = useState(null); const failedPromptRef = useRef(failedPrompt); @@ -8888,6 +8916,9 @@ export function App({ ); const statusBarRef = useRef(null); const messageListRef = useRef(null); + const locateWaitingActivity = useCallback((messageId: string) => { + messageListRef.current?.scrollToMessage(messageId); + }, []); const editorRef = useRef(null); const chatFocusBlockedRef = useRef(false); const focusChatAfterNavigation = useCallback(() => { @@ -9000,6 +9031,14 @@ export function App({ failedPromptRetryIsCurrent && (!failedPromptRetry.admitted || failedPromptRetry.settled), ); + const waitingMinigameVisible = + !suppressFailedPromptRetryStreaming && + (streamingState !== 'idle' || sessionHasActivePrompt); + const waitingGamesEnabled = useMemo(() => readWaitingGamesEnabled(), []); + const [waitingMinigameOpen, setWaitingMinigameOpen] = useState(false); + useEffect(() => { + if (!waitingMinigameVisible) setWaitingMinigameOpen(false); + }, [waitingMinigameVisible]); const streamingStateRef = useRef(streamingState); streamingStateRef.current = streamingState; // Cleared in three places: the session-switch effect, the drain loop, and @@ -20431,6 +20470,7 @@ export function App({ handleCancel, handleCycleMode, artifactPanelFullscreen, + waitingMinigameOpen, }); escLiveRef.current = { streamingState, @@ -20442,6 +20482,7 @@ export function App({ handleCancel, handleCycleMode, artifactPanelFullscreen, + waitingMinigameOpen, }; // Clear a half-armed two-press whenever the streaming/idle boundary flips — the @@ -20534,6 +20575,11 @@ export function App({ return; } + // The open waiting minigame owns Escape: its own window listener + // collapses the board. Don't also arm the two-press stream cancel + // behind it, or a second Escape would stop the turn by surprise. + if (live.waitingMinigameOpen) return; + // Streaming takes priority over clearing text (queued prompts stay intact // and drain after the turn settles); see decideEscapeIntent for the rules. const intent = decideEscapeIntent({ @@ -24870,6 +24916,16 @@ export function App({ )} + {waitingMinigameVisible && ( + setWaitingMinigameOpen(false)} + activity={waitingMinigameActivity} + tokenUsage={waitingMinigameTokens} + onSelectActivity={locateWaitingActivity} + gamesEnabled={waitingGamesEnabled} + /> + )} {streamingState !== 'idle' || sessionHasActivePrompt ? ( suppressFailedPromptRetryStreaming ? null : ( @@ -24885,6 +24941,14 @@ export function App({ connection.backgroundTurn?.label ?? connection.backgroundTurn?.kind } + trailing={ + + setWaitingMinigameOpen((value) => !value) + } + /> + } /> ) ) : newSessionSuggestion ? ( diff --git a/packages/web-shell/client/components/StreamingStatus.test.tsx b/packages/web-shell/client/components/StreamingStatus.test.tsx index 3de19856920..08877de834e 100644 --- a/packages/web-shell/client/components/StreamingStatus.test.tsx +++ b/packages/web-shell/client/components/StreamingStatus.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act } from 'react'; +import { act, type ReactNode } from 'react'; import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -48,6 +48,7 @@ function render( hasActivePrompt?: boolean; startedAt?: number; backgroundLabel?: string; + trailing?: ReactNode; } = {}, ): HTMLElement { const container = document.createElement('div'); @@ -430,3 +431,15 @@ describe('StreamingStatus response speed', () => { expect(container.textContent).not.toContain('t/s'); }); }); + +describe('StreamingStatus trailing slot', () => { + it('renders the trailing control after the status metadata', () => { + pinPhraseSelection(); + const container = render( + {}, + { trailing: }, + ); + const status = container.firstElementChild; + expect(status?.lastElementChild?.textContent).toBe('game'); + }); +}); diff --git a/packages/web-shell/client/components/StreamingStatus.tsx b/packages/web-shell/client/components/StreamingStatus.tsx index 32a9ac26ec4..8db86d8f3ef 100644 --- a/packages/web-shell/client/components/StreamingStatus.tsx +++ b/packages/web-shell/client/components/StreamingStatus.tsx @@ -1,4 +1,10 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { + useCallback, + useState, + useEffect, + useRef, + type ReactNode, +} from 'react'; import { PHRASE_CHANGE_INTERVAL_MS, getLoadingPhrases, @@ -28,6 +34,8 @@ interface StreamingStatusProps { */ hasActivePrompt?: boolean; showResponseTokensPerSecond?: boolean; + /** Optional control rendered after the status metadata, e.g. the minigame. */ + trailing?: ReactNode; } const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -38,6 +46,7 @@ export function StreamingStatus({ showPhrase = true, hasActivePrompt, showResponseTokensPerSecond = false, + trailing, }: StreamingStatusProps) { const streamingState = useStreamingState(); const { estimatedOutputTokens, isReceivingContent } = @@ -165,6 +174,7 @@ export function StreamingStatus({ {tokenStr} {tokenRateStr} · {t('stream.cancel')} + {trailing} ); } diff --git a/packages/web-shell/client/components/WaitingMinigame.module.css b/packages/web-shell/client/components/WaitingMinigame.module.css new file mode 100644 index 00000000000..0a752da1ae8 --- /dev/null +++ b/packages/web-shell/client/components/WaitingMinigame.module.css @@ -0,0 +1,660 @@ +.root { + flex: 0 0 auto; + margin-bottom: 6px; + user-select: none; + -webkit-user-select: none; + color: var(--card-foreground); +} + +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.trigger:hover, +.trigger[aria-expanded='true'] { + background: var(--accent); + color: var(--card-foreground); +} + +.trigger:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.title { + overflow: hidden; + color: var(--card-foreground); + font-family: Georgia, 'Times New Roman', serif; + font-size: 14px; + font-style: italic; + font-weight: 600; + line-height: 18px; + letter-spacing: 0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 10px 12px 8px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--card); + color: var(--card-foreground); + container-type: inline-size; + animation: waiting-minigame-in 160ms ease-out both; +} + +.head { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; +} + +.head .title { + flex: 1; + text-align: center; +} + +.headSpacer { + flex: 0 0 auto; + width: 28px; + height: 24px; +} + +.viewTabs { + display: flex; + align-items: center; + gap: 4px; + min-height: 24px; +} + +.viewTab { + height: 24px; + padding: 0 10px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; + cursor: pointer; +} + +.viewTab:hover { + background: var(--accent); + color: var(--card-foreground); +} + +.viewTab[aria-selected='true'] { + background: var(--accent); + color: var(--card-foreground); + font-weight: 600; +} + +.viewTab:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.body { + min-height: calc(100cqw / 44 * 7); +} + +.heatCalendar { + position: relative; + width: 100%; + aspect-ratio: 44 / 7; +} + +.heatColumns { + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(7, 1fr); + gap: 2px; + width: 100%; + height: 100%; +} + +.heatMonths { + display: grid; + flex: 1 1 auto; + gap: 2px; + width: 100%; + height: 13px; + color: var(--muted-foreground); + font-size: 10px; + line-height: 13px; +} + +.heatMonth { + overflow: visible; + white-space: nowrap; +} + +.heatTooltip { + position: absolute; + bottom: calc(100% + 8px); + z-index: 4; + display: flex; + flex-direction: column; + gap: 1px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--popover, var(--card)); + color: var(--popover-foreground, var(--card-foreground)); + box-shadow: 0 4px 14px rgb(0 0 0 / 14%); + font-size: 11px; + line-height: 15px; + white-space: nowrap; + pointer-events: none; +} + +.heatTooltip[data-align='center'] { + transform: translateX(-50%); +} + +.heatTooltip[data-align='start'] { + left: 0; +} + +.heatTooltip[data-align='end'] { + right: 0; +} + +.heatTooltipHint { + color: var(--muted-foreground); + font-size: 10px; + line-height: 14px; +} + +.heatCell { + min-width: 0; + min-height: 0; + padding: 0; + border: 0; + border-radius: 2px; + background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); + cursor: pointer; +} + +.heatCell[data-empty='true'] { + cursor: default; +} + +.heatCell[data-future='true'] { + background: transparent; + pointer-events: none; +} + +.heatCell:hover, +.heatCell:focus-visible { + outline: 1px solid color-mix(in srgb, var(--foreground) 42%, transparent); + outline-offset: 1px; + filter: brightness(1.15); +} + +.heatCell:focus-visible { + outline-width: 2px; + outline-color: var(--ring); +} + +.heatCell[data-level='1'] { + background: color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 18%, + transparent + ); +} + +.heatCell[data-level='2'] { + background: color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 35%, + transparent + ); +} + +.heatCell[data-level='3'] { + background: color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 55%, + transparent + ); +} + +.heatCell[data-level='4'] { + background: color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 78%, + transparent + ); +} + +.heatGrid { + position: absolute; + inset: 0; + display: grid; + gap: 2px; + pointer-events: none; +} + +.gameStart { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 3px; + background: #ffffff; + box-shadow: none; + color: #1f2328; + cursor: pointer; + pointer-events: auto; + animation: waiting-game-in 320ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes waiting-game-in { + from { + opacity: 0; + transform: scale(0.4); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Keep the visual box cell-sized but give the tiny target a forgiving hit area. */ +.gameStart::after { + content: ''; + position: absolute; + inset: -6px; +} + +.gameStart:hover { + background: #f2f5ff; +} + +.gameStart:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.gameIcon { + width: 72%; + height: 72%; + display: block; +} + +.tokenView { + display: flex; + flex-direction: column; + gap: 10px; + padding: 2px 0; +} + +.tokenGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.tokenMetric { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--card) 92%, var(--foreground) 8%); +} + +.tokenMetric span { + overflow: hidden; + color: var(--muted-foreground); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tokenMetric strong { + overflow: hidden; + color: var(--card-foreground); + font-size: 16px; + font-variant-numeric: tabular-nums; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tokenEmpty { + padding: 10px 0; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; + text-align: center; +} + +.context { + display: flex; + flex-direction: column; + gap: 4px; +} + +.contextHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted-foreground); + font-size: 11px; + line-height: 16px; +} + +.contextTrack { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--muted-foreground) 14%, transparent); +} + +.contextFill { + display: block; + height: 100%; + border-radius: 999px; + background: var(--chat-editor-accent-color, #4a9eff); +} + +.board[data-intro='true'] .cell[data-state='2'] { + animation: waiting-intro-cell 220ms ease-out backwards; + box-shadow: 0 0 8px + color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 55%, + transparent + ); +} + +/* 入场结束后方块整体飞向右侧,落成开局预置方块 */ +.board[data-intro='true'][data-settling='true'] .cell[data-state='2'] { + opacity: 0.18; + transform: translate( + calc(var(--intro-dx, 0) * (100% + 2px)), + calc(var(--intro-dy, 0) * (100% + 2px)) + ); + transition: + transform 520ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 180ms ease 340ms; +} + +.board[data-intro='true'][data-landed='true'] .cell[data-state='1'] { + animation: waiting-intro-land 240ms cubic-bezier(0.22, 1, 0.36, 1) both; + box-shadow: 0 0 8px + color-mix(in srgb, var(--success-color, #22c55e) 55%, transparent); +} + +@keyframes waiting-intro-cell { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes waiting-intro-land { + from { + opacity: 0.35; + transform: scale(0.82); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.cells[data-game='snake'] .cell[data-state='1'] { + background: color-mix( + in srgb, + var(--chat-editor-accent-color, #4a9eff) 55%, + transparent + ); +} + +.cells[data-game='snake'] .cell[data-state='2'] { + background: var(--chat-editor-accent-color, #4a9eff); +} + +.cells[data-game='snake'] .cell[data-state='3'] { + display: flex; + align-items: center; + justify-content: center; + border-radius: 0; + background: transparent; + font-size: calc(100cqw / 44 * 0.9); + line-height: 1; +} + +@media (max-width: 700px) { + .tokenGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.textButton, +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 24px; + padding: 0 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; + cursor: pointer; +} + +.iconButton { + width: 28px; + padding: 0; +} + +.textButton:hover, +.iconButton:hover { + background: var(--accent); + color: var(--card-foreground); +} + +.textButton:focus-visible, +.iconButton:focus-visible, +.restartButton:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.board { + position: relative; + width: 100%; + aspect-ratio: 44 / 7; + container-type: inline-size; +} + +.cells { + display: grid; + grid-auto-rows: 1fr; + gap: 2px; + width: 100%; + height: 100%; +} + +/* 飞行层叠在静态底板上,方块离开后原位置不会露出卡片底色 */ +.introCells { + position: absolute; + inset: 0; +} + +.cell { + min-width: 0; + min-height: 0; + border-radius: 2px; + background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); +} + +.cell[data-state='1'] { + background: color-mix( + in srgb, + var(--success-color, #22c55e) 62%, + transparent + ); +} + +.cell[data-state='2'] { + background: var(--chat-editor-accent-color, #4a9eff); +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 6px; + background: color-mix(in srgb, var(--card) 82%, transparent); + backdrop-filter: blur(2px); +} + +.overlayTitle { + color: var(--card-foreground); + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.overlayHint { + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; +} + +.restartButton { + height: 24px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); + color: var(--chat-editor-accent-color, #4a9eff); + font-size: 12px; + line-height: 16px; + cursor: pointer; +} + +.restartButton:hover { + background: var(--accent); +} + +.notice { + position: absolute; + top: 6px; + right: 6px; + padding: 2px 8px; + border-radius: 999px; + background: var(--card); + box-shadow: 0 0 0 1px var(--border); + color: var(--card-foreground); + font-size: 11px; + line-height: 16px; + pointer-events: none; + animation: waiting-minigame-notice 1.2s ease-out both; +} + +.foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + min-height: 18px; + color: var(--muted-foreground); + font-size: 11px; + line-height: 16px; +} + +.score { + color: var(--card-foreground); + font-variant-numeric: tabular-nums; +} + +.controls { + flex: 1 1 auto; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes waiting-minigame-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes waiting-minigame-notice { + 0% { + opacity: 0; + transform: translateY(4px); + } + 16%, + 72% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-4px); + } +} + +@media (prefers-reduced-motion: reduce) { + .card, + .notice, + .gameStart, + .board[data-intro='true'] .cell[data-state='2'], + .board[data-intro='true'][data-landed='true'] .cell[data-state='1'] { + animation: none; + } + + .board[data-intro='true'][data-settling='true'] .cell[data-state='2'] { + transition: none; + } +} diff --git a/packages/web-shell/client/components/WaitingMinigame.test.tsx b/packages/web-shell/client/components/WaitingMinigame.test.tsx new file mode 100644 index 00000000000..9b8f738524b --- /dev/null +++ b/packages/web-shell/client/components/WaitingMinigame.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../i18n'; +import { buildWaitingActivity } from '../utils/waitingActivity'; +import { + WaitingMinigame, + WaitingMinigameButton, + type WaitingMinigameProps, +} from './WaitingMinigame'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +interface Rendered { + container: HTMLElement; + rerender: (next: WaitingMinigameProps) => void; +} + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = new Date(2026, 10, 4, 12, 0, 0).getTime(); +const activity = buildWaitingActivity( + [ + { id: 'user-old', role: 'user', timestamp: NOW - 3 * DAY_MS }, + { + id: 'assistant-old', + role: 'assistant', + timestamp: NOW - 3 * DAY_MS + 60_000, + }, + { id: 'user-today', role: 'user', timestamp: NOW - 1_000 }, + { id: 'assistant-today', role: 'assistant', timestamp: NOW - 500 }, + ], + { now: NOW }, +); +const tokens = { + input: 5_000, + output: 3_000, + cached: 1_000, + context: 900, + contextLimit: 1_000, +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0); + window.localStorage.clear(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); +}); + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function render(props: Partial = {}): Rendered { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const rerender = (next: Partial) => { + act(() => { + root.render( + + + , + ); + }); + }; + rerender(props); + mounted.push({ root, container }); + return { container, rerender }; +} + +function keyOn(target: EventTarget, keyName: string): void { + act(() => { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key: keyName, + bubbles: true, + cancelable: true, + }), + ); + }); +} + +function key(keyName: string): void { + keyOn(window, keyName); +} + +function clickButton(container: HTMLElement, label: string): void { + const button = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.includes(label), + ); + act(() => button?.click()); +} + +function clickQwenStart(container: HTMLElement): void { + act(() => + container + .querySelector( + '[data-component="waiting-minigame-start"]', + ) + ?.click(), + ); +} + +function finishIntro(): void { + act(() => { + vi.advanceTimersByTime(1_800); + }); +} + +function startRandomGame(container: HTMLElement): void { + clickQwenStart(container); + finishIntro(); +} + +function activeCellIndexes(container: HTMLElement): number[] { + return Array.from(container.querySelectorAll('[data-state]')).flatMap( + (element, index) => + element.getAttribute('data-state') === '2' ? [index] : [], + ); +} + +describe('WaitingMinigame', () => { + it('renders nothing while closed', () => { + const { container } = render({ open: false }); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect( + container.querySelector('[data-component="waiting-minigame"]'), + ).toBeNull(); + }); + + it('opens on the activity heatmap with the Qwen entry', () => { + const { container } = render(); + expect( + container.querySelector('[data-component="waiting-minigame"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Q&A activity'); + expect(container.textContent).toContain('Activity'); + expect(container.textContent).toContain('Usage'); + expect(container.querySelectorAll('[data-level]')).toHaveLength(44 * 7); + expect( + container.querySelector('[aria-label="Start a random game"]'), + ).not.toBeNull(); + expect(container.textContent).not.toContain('Score 0'); + }); + + it('keeps the game entry hidden when games are disabled', () => { + const { container } = render({ gamesEnabled: false }); + expect(container.textContent).toContain('Q&A activity'); + expect(container.textContent).toContain('Activity'); + expect(container.textContent).toContain('Usage'); + expect( + container.querySelector('[data-component="waiting-minigame-start"]'), + ).toBeNull(); + act(() => { + vi.advanceTimersByTime(6_000); + }); + expect( + container.querySelector('[data-component="waiting-minigame-start"]'), + ).toBeNull(); + clickButton(container, 'Usage'); + expect(container.textContent).toContain('Total tokens'); + }); + + it('reveals the Qwen entry after the five second delay', () => { + const { container } = render({ entryDelayMs: 5_000 }); + expect( + container.querySelector('[data-component="waiting-minigame-start"]'), + ).toBeNull(); + act(() => { + vi.advanceTimersByTime(4_999); + }); + expect( + container.querySelector('[data-component="waiting-minigame-start"]'), + ).toBeNull(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect( + container.querySelector('[data-component="waiting-minigame-start"]'), + ).not.toBeNull(); + }); + + it('keeps the Qwen entry cell stable while activity updates', () => { + const { container, rerender } = render(); + const position = () => { + const start = container.querySelector( + '[data-component="waiting-minigame-start"]', + ); + return `${start?.style.gridColumn}:${start?.style.gridRow}`; + }; + expect(position()).toBe('1:1'); + rerender({ + activity: activity.map((day, index) => ({ + ...day, + count: day.future || index === 100 ? 0 : 1, + })), + }); + expect(position()).toBe('1:1'); + }); + + it('flies the Qwen Code blocks into the Tetris start cluster', () => { + const { container } = render(); + clickQwenStart(container); + const intro = container.querySelector('[data-intro="true"]'); + expect(intro).not.toBeNull(); + act(() => { + vi.advanceTimersByTime(750); + }); + expect(intro?.getAttribute('data-settling')).toBe('true'); + act(() => { + vi.advanceTimersByTime(520); + }); + expect(intro?.getAttribute('data-landed')).toBe('true'); + expect(intro?.querySelectorAll('[data-state="1"]')).toHaveLength(12); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(container.querySelector('[data-intro="true"]')).toBeNull(); + expect(container.textContent).toContain('Score 0'); + expect(container.textContent).toContain('Best 0'); + }); + + it('keeps the Qwen Code blocks still before a Snake game', () => { + vi.mocked(Math.random).mockReturnValue(0.9); + const { container } = render(); + clickQwenStart(container); + const intro = container.querySelector('[data-intro="true"]'); + expect(intro).not.toBeNull(); + act(() => { + vi.advanceTimersByTime(750); + }); + expect(intro?.getAttribute('data-settling')).toBeNull(); + act(() => { + vi.advanceTimersByTime(600); + }); + expect(container.querySelector('[data-intro="true"]')).toBeNull(); + expect(container.textContent).toContain('Arrows / WASD turn'); + }); + + it('shows day details on hover and locates the clicked day', () => { + const onSelectActivity = vi.fn(); + const days = buildWaitingActivity( + [ + { + id: 'assistant-hover', + role: 'assistant', + timestamp: NOW - 1_000, + }, + { id: 'user-hover', role: 'user', timestamp: NOW - 500 }, + ], + { now: NOW }, + ); + const { container } = render({ + activity: days, + onSelectActivity, + }); + const active = container.querySelector( + '[data-level]:not([data-empty="true"])', + )!; + act(() => { + active.dispatchEvent( + new MouseEvent('mouseover', { bubbles: true, relatedTarget: null }), + ); + }); + expect(container.textContent).toContain('2 Q&A'); + expect(container.textContent).toContain('Click to locate'); + act(() => active.click()); + expect(onSelectActivity).toHaveBeenCalledWith('user-hover'); + + const empty = container.querySelector( + 'button[data-level][data-empty="true"]', + )!; + act(() => empty.click()); + expect(onSelectActivity).toHaveBeenCalledTimes(1); + }); + + it('shows token totals, input, output and context in the usage tab', () => { + const { container } = render(); + clickButton(container, 'Usage'); + expect(container.textContent).toContain('Total tokens'); + expect(container.textContent).toContain('8.0k'); + expect(container.textContent).toContain('5.0k'); + expect(container.textContent).toContain('3.0k'); + expect(container.textContent).toContain('1.0k'); + expect(container.textContent).toContain('900 / 1.0k'); + expect(container.textContent).not.toContain('No token data yet'); + }); + + it('shows an empty token state when usage is unavailable', () => { + const { container } = render({ + tokenUsage: { input: 0, output: 0, cached: 0 }, + }); + clickButton(container, 'Usage'); + expect(container.textContent).toContain('No token data yet'); + }); + + it('starts Tetris, pauses, resumes and closes with Escape', () => { + const onClose = vi.fn(); + const { container } = render({ onClose }); + startRandomGame(container); + expect(container.querySelectorAll('[data-state]')).toHaveLength(44 * 7); + expect(container.textContent).toContain('Score 0'); + expect(container.textContent).toContain('Best 0'); + expect(container.textContent).toContain('clear a column +50'); + + key(' '); + expect(container.textContent).toContain('Paused'); + key(' '); + expect(container.textContent).not.toContain('Paused'); + key('Escape'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('starts Snake randomly and supports pause, resume and turning', () => { + vi.mocked(Math.random).mockReturnValue(0.9); + const { container } = render(); + startRandomGame(container); + expect(container.querySelectorAll('[data-state]')).toHaveLength(44 * 7); + expect(container.textContent).toContain('Arrows / WASD turn'); + expect(container.textContent).toContain('🍎'); + key(' '); + expect(container.textContent).toContain('Paused'); + key(' '); + expect(container.textContent).not.toContain('Paused'); + const headBefore = activeCellIndexes(container); + key('ArrowUp'); + act(() => { + vi.advanceTimersByTime(240); + }); + expect(activeCellIndexes(container)).not.toEqual(headBefore); + }); + + it('returns to the activity view on reopen', () => { + const { container, rerender } = render(); + startRandomGame(container); + expect(container.textContent).toContain('Score 0'); + rerender({ open: false }); + rerender({ open: true }); + expect(container.textContent).toContain('Q&A activity'); + expect(container.textContent).not.toContain('Score 0'); + }); + + it('pauses gravity when the tab is hidden and resumes after Space', () => { + const { container } = render(); + startRandomGame(container); + expect(activeCellIndexes(container)).toEqual([0, 1, 2, 3]); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(container.textContent).toContain('Paused'); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + key(' '); + act(() => { + vi.advanceTimersByTime(140); + }); + expect(container.textContent).not.toContain('Paused'); + expect(activeCellIndexes(container)).toEqual([1, 2, 3, 4]); + }); + + it('leaves Space and Escape to focused controls and open dialogs', () => { + const onClose = vi.fn(); + const { container } = render({ onClose }); + startRandomGame(container); + const mute = container.querySelector('[aria-pressed]'); + expect(mute).not.toBeNull(); + act(() => mute?.focus()); + keyOn(mute!, ' '); + expect(container.textContent).not.toContain('Paused'); + + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const dialogButton = document.createElement('button'); + dialog.appendChild(dialogButton); + container.appendChild(dialog); + keyOn(dialogButton, 'Escape'); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('keeps game controls out of the aria-hidden board', () => { + const { container } = render(); + startRandomGame(container); + expect(container.querySelector('[aria-hidden="true"] button')).toBeNull(); + expect( + container.querySelectorAll('[aria-hidden="true"] [data-state]'), + ).toHaveLength(44 * 7); + }); +}); + +describe('WaitingMinigameButton', () => { + function renderButton(open: boolean): { + container: HTMLElement; + onToggle: ReturnType; + } { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const onToggle = vi.fn(); + act(() => { + root.render( + + + , + ); + }); + mounted.push({ root, container }); + return { container, onToggle }; + } + + it('renders an expanded state and toggles the card on click', () => { + const { container, onToggle } = renderButton(false); + const button = container.querySelector( + '[data-component="waiting-minigame-trigger"]', + ); + expect(button?.getAttribute('aria-expanded')).toBe('false'); + expect(button?.getAttribute('aria-label')).toBe('Open task activity'); + act(() => button?.click()); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('reports the open state on the trigger', () => { + const { container } = renderButton(true); + expect( + container + .querySelector('[data-component="waiting-minigame-trigger"]') + ?.getAttribute('aria-expanded'), + ).toBe('true'); + }); +}); diff --git a/packages/web-shell/client/components/WaitingMinigame.tsx b/packages/web-shell/client/components/WaitingMinigame.tsx new file mode 100644 index 00000000000..42fb1142366 --- /dev/null +++ b/packages/web-shell/client/components/WaitingMinigame.tsx @@ -0,0 +1,1344 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from 'react'; +import { Gamepad2Icon } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { isCoarsePointerDevice } from '../hooks/useIsTouchComposer'; +import { formatContextTokens } from '../utils/formatTokenCount'; +import { + WAITING_ACTIVITY_CELLS, + WAITING_ACTIVITY_DAYS, + WAITING_ACTIVITY_WEEKS, + type WaitingActivityDay, + type WaitingTokenUsage, +} from '../utils/waitingActivity'; +import { + WAITING_MINIGAME_CLEAR_SCORE, + WAITING_MINIGAME_COLS, + WAITING_MINIGAME_ROWS, + buildWaitingMinigameStartCluster, + createWaitingMinigame, + dropWaitingMinigamePiece, + moveWaitingMinigamePiece, + rotateWaitingMinigamePiece, + setWaitingMinigamePaused, + tickWaitingMinigame, + toggleWaitingMinigamePause, + waitingMinigameRenderGrid, + waitingMinigameTickMs, + type WaitingMinigameState, +} from '../utils/waitingMinigame'; +import { + createWaitingSnake, + setWaitingSnakePaused, + tickWaitingSnake, + toggleWaitingSnakePause, + turnWaitingSnake, + waitingSnakeRenderGrid, + waitingSnakeTickMs, + type WaitingSnakeDirection, + type WaitingSnakeState, +} from '../utils/waitingSnake'; +import styles from './WaitingMinigame.module.css'; + +const BEST_SCORE_KEY = 'qwen-code-web-shell-waiting-minigame-best'; +const SNAKE_BEST_SCORE_KEY = 'qwen-code-web-shell-waiting-minigame-snake-best'; +const SOUND_KEY = 'qwen-code-web-shell-waiting-minigame-sound'; +const NOTICE_MS = 1_200; +const CELLS = Array.from( + { length: WAITING_MINIGAME_ROWS * WAITING_MINIGAME_COLS }, + (_, index) => index, +); +const QWEN_CELL_INDEX = Math.floor(WAITING_ACTIVITY_CELLS / 2); +// 文字出现后整体飞向右侧堆,落定后再切到游戏面板 +const INTRO_FLIGHT_MS = 750; +const INTRO_LAND_MS = 1_270; +const INTRO_MS = 1_700; +// 贪吃蛇没有右侧堆,文字停留后直接进入游戏 +const INTRO_SNAKE_MS = 1_250; +const INTRO_FONT: Record = { + Q: ['1111', '1001', '1001', '1011', '1111'], + W: ['1001', '1001', '1001', '1011', '1111'], + E: ['1111', '1000', '1110', '1000', '1111'], + N: ['1001', '1101', '1101', '1011', '1001'], + C: ['0111', '1000', '1000', '1000', '0111'], + O: ['0110', '1001', '1001', '1001', '0110'], + D: ['1110', '1001', '1001', '1001', '1110'], +}; + +function buildIntroCells(): ReadonlySet { + const cells = new Set(); + let col = 2; + for (const char of 'QWEN CODE') { + if (char === ' ') { + col += 2; + continue; + } + const rows = INTRO_FONT[char]; + if (!rows) continue; + rows.forEach((row, rowIndex) => { + for (let offset = 0; offset < row.length; offset++) { + if (row[offset] === '1') { + cells.add((rowIndex + 1) * WAITING_MINIGAME_COLS + col + offset); + } + } + }); + col += rows[0]!.length + 1; + } + return cells; +} + +const INTRO_CELLS = buildIntroCells(); + +/** + * 每个字符方块飞向右侧堆里高度最接近的格子,保持大致水平移动, + * 同高候选中按源列轮换,避免所有方块叠到同一格。 + */ +function buildIntroFlight( + targets: readonly number[], +): ReadonlyMap { + const flight = new Map(); + if (targets.length === 0) return flight; + for (const source of INTRO_CELLS) { + const sourceRow = Math.floor(source / WAITING_MINIGAME_COLS); + let bestDistance = Number.POSITIVE_INFINITY; + const candidates: number[] = []; + for (const target of targets) { + const distance = Math.abs( + Math.floor(target / WAITING_MINIGAME_COLS) - sourceRow, + ); + if (distance < bestDistance) { + bestDistance = distance; + candidates.length = 0; + candidates.push(target); + } else if (distance === bestDistance) { + candidates.push(target); + } + } + flight.set(source, candidates[source % candidates.length]!); + } + return flight; +} + +type CardView = 'heatmap' | 'tokens' | 'intro' | 'tetris' | 'snake'; +type GameKind = 'tetris' | 'snake'; + +function readBestScore(key: string): number { + try { + const raw = window.localStorage.getItem(key); + const value = raw ? Number.parseInt(raw, 10) : 0; + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + } catch { + return 0; + } +} + +function writeBestScore(key: string, value: number): void { + try { + window.localStorage.setItem(key, String(value)); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +function readSoundEnabled(): boolean { + try { + const raw = window.localStorage.getItem(SOUND_KEY); + return raw === null ? true : raw === '1'; + } catch { + return true; + } +} + +function writeSoundEnabled(value: boolean): void { + try { + window.localStorage.setItem(SOUND_KEY, value ? '1' : '0'); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +type AudioContextConstructor = new () => AudioContext; + +function audioContextConstructor(): AudioContextConstructor | undefined { + const scope = window as unknown as { + AudioContext?: AudioContextConstructor; + webkitAudioContext?: AudioContextConstructor; + }; + return scope.AudioContext ?? scope.webkitAudioContext; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + if (target instanceof HTMLElement && target.isContentEditable) return true; + return Boolean( + target.closest( + 'input, textarea, select, [contenteditable="true"], [role="textbox"], .cm-content, [data-lexical-editor], .ProseMirror', + ), + ); +} + +function isVisibilityHidden(): boolean { + return ( + typeof document !== 'undefined' && document.visibilityState === 'hidden' + ); +} + +function activityLevel(day: WaitingActivityDay, maxCount: number): number { + if (day.count <= 0) return 0; + if (maxCount <= 0) return 1; + return Math.min(4, Math.max(1, Math.ceil((day.count / maxCount) * 4))); +} + +function snakeDirectionForKey(key: string): WaitingSnakeDirection | undefined { + switch (key) { + case 'ArrowUp': + case 'w': + case 'W': + return 'up'; + case 'ArrowDown': + case 's': + case 'S': + return 'down'; + case 'ArrowLeft': + case 'a': + case 'A': + return 'left'; + case 'ArrowRight': + case 'd': + case 'D': + return 'right'; + default: + return undefined; + } +} + +export interface WaitingMinigameProps { + open: boolean; + onClose: () => void; + activity?: readonly WaitingActivityDay[]; + tokenUsage?: WaitingTokenUsage; + /** Jump to the message that represents the clicked activity bucket. */ + onSelectActivity?: (messageId: string) => void; + /** Game entry is opt-in; the activity and token views are always available. */ + gamesEnabled?: boolean; + /** Delay before the game entry appears; the product default is 5s. */ + entryDelayMs?: number; +} + +export function WaitingMinigame({ + open, + onClose, + activity = [], + tokenUsage = { input: 0, output: 0, cached: 0 }, + onSelectActivity, + gamesEnabled = false, + entryDelayMs = 5_000, +}: WaitingMinigameProps) { + const { t, language } = useI18n(); + const [view, setView] = useState('heatmap'); + const [game, setGame] = useState(() => + createWaitingMinigame(), + ); + const gameRef = useRef(game); + const [snake, setSnake] = useState(() => + createWaitingSnake(), + ); + const snakeRef = useRef(snake); + const [best, setBest] = useState(() => readBestScore(BEST_SCORE_KEY)); + const [snakeBest, setSnakeBest] = useState(() => + readBestScore(SNAKE_BEST_SCORE_KEY), + ); + const [sound, setSound] = useState(readSoundEnabled); + const [notice, setNotice] = useState<{ + count: number; + score: number; + } | null>(null); + const [documentVisible, setDocumentVisible] = useState( + () => !isVisibilityHidden(), + ); + const [introTargets, setIntroTargets] = useState([]); + const [introSettling, setIntroSettling] = useState(false); + const [introLanded, setIntroLanded] = useState(false); + const [hoveredCell, setHoveredCell] = useState(null); + const [qwenCellIndex, setQwenCellIndex] = useState(QWEN_CELL_INDEX); + const [qwenEntryReady, setQwenEntryReady] = useState(false); + const entryTimer = useRef(undefined); + const noticeTimer = useRef(undefined); + const introTimer = useRef(undefined); + const introRevealTimer = useRef(undefined); + const introLandTimer = useRef(undefined); + const audioContext = useRef(undefined); + const rootRef = useRef(null); + const soundRef = useRef(sound); + const previousCleared = useRef(game.cleared); + const previousOver = useRef(game.over); + const previousSnakeScore = useRef(snake.score); + const previousSnakeOver = useRef(snake.over); + const previousOpen = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const coarsePointer = isCoarsePointerDevice(); + const gameView = view === 'tetris' || view === 'snake'; + const activityRef = useRef(activity); + activityRef.current = activity; + const introFlight = useMemo( + () => buildIntroFlight(introTargets), + [introTargets], + ); + + useEffect(() => { + gameRef.current = game; + }, [game]); + + useEffect(() => { + snakeRef.current = snake; + }, [snake]); + + useEffect(() => { + soundRef.current = sound; + }, [sound]); + + const playSound = useCallback((kind: 'clear' | 'over' | 'toggle') => { + if (!soundRef.current) return; + const context = audioContext.current; + if (!context) return; + const notes = + kind === 'clear' ? [660, 880] : kind === 'over' ? [392, 294, 196] : [520]; + try { + const now = context.currentTime; + notes.forEach((frequency, index) => { + const start = now + index * 0.08; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.045, start + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.13); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(start); + oscillator.stop(start + 0.15); + }); + } catch { + // Audio is decorative; a blocked context must not break the game. + } + }, []); + + const ensureAudio = useCallback(() => { + if (audioContext.current) { + void audioContext.current.resume?.(); + return; + } + const Constructor = audioContextConstructor(); + if (!Constructor) return; + try { + audioContext.current = new Constructor(); + } catch { + audioContext.current = undefined; + } + }, []); + + const apply = useCallback((next: WaitingMinigameState) => { + const current = gameRef.current; + if (next === current) return; + gameRef.current = next; + setGame(next); + }, []); + + const applySnake = useCallback((next: WaitingSnakeState) => { + const current = snakeRef.current; + if (next === current) return; + snakeRef.current = next; + setSnake(next); + }, []); + + const cancelIntro = useCallback(() => { + if (introTimer.current !== undefined) { + window.clearTimeout(introTimer.current); + introTimer.current = undefined; + } + if (introRevealTimer.current !== undefined) { + window.clearTimeout(introRevealTimer.current); + introRevealTimer.current = undefined; + } + if (introLandTimer.current !== undefined) { + window.clearTimeout(introLandTimer.current); + introLandTimer.current = undefined; + } + setIntroTargets([]); + setIntroSettling(false); + setIntroLanded(false); + }, []); + + useEffect(() => { + if (previousOpen.current === open) return; + previousOpen.current = open; + cancelIntro(); + if (open) { + setView('heatmap'); + return; + } + setQwenEntryReady(false); + if (!gameRef.current.paused && !gameRef.current.over) { + apply(setWaitingMinigamePaused(gameRef.current, true)); + } + if (!snakeRef.current.paused && !snakeRef.current.over) { + applySnake(setWaitingSnakePaused(snakeRef.current, true)); + } + }, [open, apply, applySnake, cancelIntro]); + + useEffect(() => { + if (view !== 'heatmap') setHoveredCell(null); + }, [view]); + + useEffect(() => { + if (!open || !documentVisible || view !== 'tetris') return; + if (game.paused || game.over) return; + const timer = window.setInterval(() => { + apply(tickWaitingMinigame(gameRef.current)); + }, waitingMinigameTickMs(game.cleared)); + return () => window.clearInterval(timer); + }, [ + open, + documentVisible, + view, + game.paused, + game.over, + game.cleared, + apply, + ]); + + useEffect(() => { + if (!open || !documentVisible || view !== 'snake') return; + if (snake.paused || snake.over) return; + const timer = window.setInterval(() => { + applySnake(tickWaitingSnake(snakeRef.current)); + }, waitingSnakeTickMs(snake.score)); + return () => window.clearInterval(timer); + }, [ + open, + documentVisible, + view, + snake.paused, + snake.over, + snake.score, + applySnake, + ]); + + useEffect(() => { + if (!open) return; + const onVisibility = () => { + const visibleNow = !isVisibilityHidden(); + setDocumentVisible(visibleNow); + if (visibleNow) return; + if (view === 'tetris') { + const current = gameRef.current; + if (!current.paused && !current.over) { + apply(setWaitingMinigamePaused(current, true)); + } + } else if (view === 'snake') { + const current = snakeRef.current; + if (!current.paused && !current.over) { + applySnake(setWaitingSnakePaused(current, true)); + } + } + }; + document.addEventListener('visibilitychange', onVisibility); + onVisibility(); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [open, view, apply, applySnake]); + + useEffect(() => { + if (!open || !gamesEnabled) return; + setQwenEntryReady(false); + if (entryTimer.current !== undefined) { + window.clearTimeout(entryTimer.current); + entryTimer.current = undefined; + } + if (entryDelayMs <= 0) { + setQwenEntryReady(true); + return; + } + entryTimer.current = window.setTimeout(() => { + entryTimer.current = undefined; + setQwenEntryReady(true); + }, entryDelayMs); + return () => { + if (entryTimer.current !== undefined) { + window.clearTimeout(entryTimer.current); + entryTimer.current = undefined; + } + }; + }, [open, gamesEnabled, entryDelayMs]); + + useEffect(() => { + if (gamesEnabled) return; + cancelIntro(); + setView('heatmap'); + setQwenEntryReady(false); + }, [gamesEnabled, cancelIntro]); + + useEffect(() => { + if (!qwenEntryReady) return; + const days = activityRef.current; + if (days.length === 0) return; + let coldest = Number.POSITIVE_INFINITY; + const candidates: number[] = []; + days.forEach((day, index) => { + if (day.future) return; + if (day.count < coldest) { + coldest = day.count; + candidates.length = 0; + candidates.push(index); + } else if (day.count === coldest) { + candidates.push(index); + } + }); + const index = candidates[Math.floor(Math.random() * candidates.length)]; + if (index !== undefined) setQwenCellIndex(index); + }, [qwenEntryReady]); + + useEffect(() => { + if (game.score > best) { + setBest(game.score); + writeBestScore(BEST_SCORE_KEY, game.score); + } + }, [game.score, best]); + + useEffect(() => { + if (snake.score > snakeBest) { + setSnakeBest(snake.score); + writeBestScore(SNAKE_BEST_SCORE_KEY, snake.score); + } + }, [snake.score, snakeBest]); + + useEffect(() => { + if (game.cleared > previousCleared.current) { + const count = game.cleared - previousCleared.current; + setNotice({ count, score: count * WAITING_MINIGAME_CLEAR_SCORE }); + if (noticeTimer.current !== undefined) { + window.clearTimeout(noticeTimer.current); + } + noticeTimer.current = window.setTimeout(() => setNotice(null), NOTICE_MS); + playSound('clear'); + } + if (game.over && !previousOver.current) playSound('over'); + previousCleared.current = game.cleared; + previousOver.current = game.over; + }, [game.cleared, game.over, playSound]); + + useEffect(() => { + if (snake.score > previousSnakeScore.current) playSound('clear'); + if (snake.over && !previousSnakeOver.current) playSound('over'); + previousSnakeScore.current = snake.score; + previousSnakeOver.current = snake.over; + }, [snake.score, snake.over, playSound]); + + useEffect(() => { + return () => { + if (noticeTimer.current !== undefined) { + window.clearTimeout(noticeTimer.current); + } + if (introTimer.current !== undefined) { + window.clearTimeout(introTimer.current); + } + if (introRevealTimer.current !== undefined) { + window.clearTimeout(introRevealTimer.current); + } + if (introLandTimer.current !== undefined) { + window.clearTimeout(introLandTimer.current); + } + try { + void audioContext.current?.close?.(); + } catch { + // Ignore teardown errors from an already-closed context. + } + }; + }, []); + + const restartTetris = useCallback(() => { + apply(createWaitingMinigame()); + setNotice(null); + }, [apply]); + + const restartSnake = useCallback(() => { + applySnake(createWaitingSnake()); + }, [applySnake]); + + const startFreshGame = useCallback( + (kind: GameKind, cluster: readonly number[]) => { + if (kind === 'tetris') { + apply(createWaitingMinigame({ initialSettled: cluster })); + setNotice(null); + } else { + applySnake(createWaitingSnake()); + } + ensureAudio(); + setView(kind); + }, + [apply, applySnake, ensureAudio], + ); + + const restartActiveGame = useCallback(() => { + if (view === 'snake') restartSnake(); + else if (view === 'tetris') restartTetris(); + }, [restartSnake, restartTetris, view]); + + const beginIntroGame = useCallback( + (kind: GameKind) => { + cancelIntro(); + ensureAudio(); + setView('intro'); + if (kind === 'snake') { + introTimer.current = window.setTimeout(() => { + introTimer.current = undefined; + startFreshGame('snake', []); + }, INTRO_SNAKE_MS); + return; + } + const cluster = buildWaitingMinigameStartCluster(); + setIntroTargets(cluster); + setIntroSettling(false); + setIntroLanded(false); + introRevealTimer.current = window.setTimeout(() => { + introRevealTimer.current = undefined; + setIntroSettling(true); + }, INTRO_FLIGHT_MS); + introLandTimer.current = window.setTimeout(() => { + introLandTimer.current = undefined; + setIntroLanded(true); + }, INTRO_LAND_MS); + introTimer.current = window.setTimeout(() => { + introTimer.current = undefined; + startFreshGame(kind, cluster); + }, INTRO_MS); + }, + [cancelIntro, ensureAudio, startFreshGame], + ); + + const startRandomGame = useCallback(() => { + beginIntroGame(Math.random() < 0.5 ? 'tetris' : 'snake'); + }, [beginIntroGame]); + + const toggleSound = useCallback(() => { + const next = !sound; + soundRef.current = next; + setSound(next); + writeSoundEnabled(next); + if (next) { + ensureAudio(); + playSound('toggle'); + } + }, [ensureAudio, playSound, sound]); + + useEffect(() => { + if (!open || coarsePointer) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if ( + event + .composedPath() + .some( + (node) => + node instanceof Element && + node.matches( + '[data-web-shell-dialog], [role="dialog"], [role="alertdialog"]', + ), + ) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onCloseRef.current(); + return; + } + if (!gameView || event.metaKey || event.ctrlKey || event.altKey) return; + if (isEditableTarget(event.target)) return; + const target = event.target; + const nativeControl = + target instanceof Element && + Boolean(rootRef.current?.contains(target)) && + Boolean( + target.closest('button, a[href], [role="button"], [role="link"]'), + ); + if ( + nativeControl && + (event.key === ' ' || event.code === 'Space' || event.key === 'Enter') + ) { + return; + } + if (event.key === ' ' || event.code === 'Space') { + if (!event.repeat) { + event.preventDefault(); + if (view === 'tetris') { + apply(toggleWaitingMinigamePause(gameRef.current)); + } else { + applySnake(toggleWaitingSnakePause(snakeRef.current)); + } + } + return; + } + if (event.key === 'Enter') { + if (event.repeat) return; + const active = view === 'tetris' ? gameRef.current : snakeRef.current; + if (active.over) { + event.preventDefault(); + restartActiveGame(); + } + return; + } + if (view === 'tetris') { + if (gameRef.current.paused || gameRef.current.over) return; + switch (event.key) { + case 'ArrowUp': + event.preventDefault(); + apply(moveWaitingMinigamePiece(gameRef.current, -1)); + break; + case 'ArrowDown': + event.preventDefault(); + apply(moveWaitingMinigamePiece(gameRef.current, 1)); + break; + case 'ArrowRight': + event.preventDefault(); + apply(dropWaitingMinigamePiece(gameRef.current)); + break; + case 'ArrowLeft': + event.preventDefault(); + apply(rotateWaitingMinigamePiece(gameRef.current)); + break; + } + return; + } + if (snakeRef.current.paused || snakeRef.current.over) return; + const direction = snakeDirectionForKey(event.key); + if (!direction) return; + event.preventDefault(); + applySnake(turnWaitingSnake(snakeRef.current, direction)); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [ + open, + coarsePointer, + gameView, + view, + apply, + applySnake, + restartActiveGame, + ]); + + const locale = + language === 'zh-CN' ? 'zh-CN' : language === 'ru' ? 'ru-RU' : 'en'; + const { formatDay, formatMonth } = useMemo(() => { + const dayFormatter = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'short' }); + return { + formatDay: (value: number) => dayFormatter.format(new Date(value)), + formatMonth: (value: number) => monthFormatter.format(new Date(value)), + }; + }, [locale]); + + if (!open || coarsePointer) return null; + + const days = activity; + const maxCount = Math.max( + 1, + ...days.filter((day) => !day.future).map((day) => day.count), + ); + const tetrisGrid = waitingMinigameRenderGrid(game); + const snakeGrid = waitingSnakeRenderGrid(snake); + const dayLabel = (day: WaitingActivityDay) => + t('waitingGame.heatmapTooltip', { + time: formatDay(day.startAt), + count: day.count, + }); + const monthOf = (index: number) => + days[index] ? new Date(days[index]!.startAt).getMonth() : undefined; + const monthLabels = Array.from( + { length: WAITING_ACTIVITY_WEEKS }, + (_, week) => { + const startIndex = week * WAITING_ACTIVITY_DAYS; + if (!days[startIndex]) return ''; + if (week > 0 && monthOf(startIndex) !== monthOf(startIndex - 1)) { + return formatMonth(days[startIndex]!.startAt); + } + for (let offset = 1; offset < WAITING_ACTIVITY_DAYS; offset++) { + if (monthOf(startIndex + offset) !== monthOf(startIndex + offset - 1)) { + return formatMonth(days[startIndex + offset]!.startAt); + } + } + if (week === 0) { + return monthOf(startIndex + 7) === monthOf(startIndex) + ? formatMonth(days[startIndex]!.startAt) + : ''; + } + return ''; + }, + ); + const qwenColumn = Math.floor(qwenCellIndex / WAITING_ACTIVITY_DAYS); + const qwenRow = qwenCellIndex % WAITING_ACTIVITY_DAYS; + const hoveredDay = hoveredCell !== null ? days[hoveredCell] : undefined; + const hoveredColumn = + hoveredCell !== null ? Math.floor(hoveredCell / WAITING_ACTIVITY_DAYS) : 0; + const totalTokens = tokenUsage.input + tokenUsage.output; + const hasTokenUsage = totalTokens > 0 || tokenUsage.cached > 0; + const contextPercent = + tokenUsage.contextLimit && tokenUsage.contextLimit > 0 + ? Math.min( + 100, + ((tokenUsage.context ?? 0) / tokenUsage.contextLimit) * 100, + ) + : 0; + + return ( +
+
+
+ + + {view === 'heatmap' + ? t('waitingGame.activityTitle') + : view === 'tokens' + ? t('waitingGame.usageTitle') + : t('waitingGame.title')} + + {gameView ? ( + + ) : ( +
+ +
+ {(view === 'heatmap' || view === 'tokens') && ( + <> + + + + )} +
+ +
+ {view === 'intro' && ( +
+ + +
+ )} + + {view === 'heatmap' && ( +
+ {qwenEntryReady && gamesEnabled && ( +
+ +
+ )} +
+ {days.map((day, index) => { + if (day.future) { + return ( +
+ {hoveredDay && !hoveredDay.future && ( +
WAITING_ACTIVITY_WEEKS - 5 + ? 'end' + : 'center' + } + style={ + hoveredColumn >= 4 && + hoveredColumn <= WAITING_ACTIVITY_WEEKS - 5 + ? { + left: `${((hoveredColumn + 0.5) / WAITING_ACTIVITY_WEEKS) * 100}%`, + } + : undefined + } + role="tooltip" + > + {dayLabel(hoveredDay)} + {hoveredDay.messageId && ( + + {t('waitingGame.jumpToMessage')} + + )} +
+ )} +
+ )} + + {view === 'tokens' && ( +
+ {hasTokenUsage ? ( +
+
+ {t('waitingGame.tokenTotal')} + + {formatContextTokens(totalTokens)} + +
+
+ {t('waitingGame.tokenInput')} + + {formatContextTokens(tokenUsage.input)} + +
+
+ {t('waitingGame.tokenOutput')} + + {formatContextTokens(tokenUsage.output)} + +
+
+ {t('waitingGame.tokenCached')} + + {formatContextTokens(tokenUsage.cached)} + +
+
+ ) : ( +
+ {t('waitingGame.tokenEmpty')} +
+ )} + {contextPercent > 0 && ( +
+
+ {t('waitingGame.tokenContext')} + + {formatContextTokens(tokenUsage.context ?? 0)} /{' '} + {formatContextTokens(tokenUsage.contextLimit ?? 0)} + +
+
+ +
+
+ )} +
+ )} + + {view === 'tetris' && ( + <> +
+ + {game.paused && !game.over && ( +
+ + {t('waitingGame.paused')} + + + {t('waitingGame.pausedHint')} + +
+ )} + {game.over && ( +
+ + {t('waitingGame.gameOver')} + + +
+ )} + {notice && ( + + {t('waitingGame.cleared', notice)} + + )} +
+ + )} + + {view === 'snake' && ( + <> +
+ + {snake.paused && !snake.over && ( +
+ + {t('waitingGame.paused')} + + + {t('waitingGame.pausedHint')} + +
+ )} + {snake.over && ( +
+ + {t('waitingGame.gameOver')} + + +
+ )} +
+ + )} +
+ +
+ {view === 'heatmap' && ( + + )} + {view === 'tetris' && ( + <> + + {t('waitingGame.score', { score: game.score })} + + {t('waitingGame.best', { score: best })} + + {t('waitingGame.controls')} + + + )} + {view === 'snake' && ( + <> + + {t('waitingGame.score', { score: snake.score })} + + {t('waitingGame.best', { score: snakeBest })} + + {t('waitingGame.snakeControls')} + + + )} +
+
+
+ ); +} + +export interface WaitingMinigameButtonProps { + open: boolean; + onToggle: () => void; +} + +export function WaitingMinigameButton({ + open, + onToggle, +}: WaitingMinigameButtonProps) { + const { t } = useI18n(); + if (isCoarsePointerDevice()) return null; + return ( + + ); +} + +function HeatmapIcon() { + return ( + + + + + + + + + + + + + + ); +} + +function SoundIcon({ muted }: { muted: boolean }) { + return ( + + + {muted ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/web-shell/client/e2e/waiting-minigame-harness.html b/packages/web-shell/client/e2e/waiting-minigame-harness.html new file mode 100644 index 00000000000..57e53bc397d --- /dev/null +++ b/packages/web-shell/client/e2e/waiting-minigame-harness.html @@ -0,0 +1,12 @@ + + + + + + Waiting minigame harness + + +
+ + + diff --git a/packages/web-shell/client/e2e/waiting-minigame-harness.tsx b/packages/web-shell/client/e2e/waiting-minigame-harness.tsx new file mode 100644 index 00000000000..d294b3ddc29 --- /dev/null +++ b/packages/web-shell/client/e2e/waiting-minigame-harness.tsx @@ -0,0 +1,240 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom/client'; +import '../styles/globals.css'; +import '../styles/standalone.css'; +import { I18nProvider, type WebShellLanguage } from '../i18n'; +import { + WaitingMinigame, + WaitingMinigameButton, +} from '../components/WaitingMinigame'; +import { + buildWaitingActivity, + type WaitingActivityDay, + type WaitingActivityMessage, +} from '../utils/waitingActivity'; +import { readWaitingGamesEnabled } from '../utils/waitingGamesConfig'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeActivity(seed: number): WaitingActivityDay[] { + const now = Date.now(); + const messages: WaitingActivityMessage[] = []; + for (let daysAgo = 0; daysAgo < 300; daysAgo++) { + if ((daysAgo + seed) % 5 !== 0) continue; + const timestamp = now - daysAgo * DAY_MS; + messages.push({ id: `harness-user-${daysAgo}`, role: 'user', timestamp }); + messages.push({ + id: `harness-assistant-${daysAgo}`, + role: 'assistant', + timestamp: timestamp + 60_000, + }); + } + return buildWaitingActivity(messages, { now }); +} + +const TOKENS = { + input: 48_200, + output: 12_600, + cached: 31_400, + context: 61_000, + contextLimit: 131_072, +}; + +function Harness() { + const [open, setOpen] = useState(true); + const [dark, setDark] = useState(true); + const [language, setLanguage] = useState('en'); + const [seed, setSeed] = useState(1); + const [withTokens, setWithTokens] = useState(true); + const [entryDelayMs, setEntryDelayMs] = useState(0); + const [gamesEnabled, setGamesEnabled] = useState(() => + readWaitingGamesEnabled(), + ); + const [located, setLocated] = useState(''); + const [nextGame, setNextGame] = useState<'random' | 'tetris' | 'snake'>( + 'random', + ); + const activity = useMemo(() => makeActivity(seed), [seed]); + const tokenUsage = withTokens ? TOKENS : { input: 0, output: 0, cached: 0 }; + const toggleTheme = () => setDark((value) => !value); + + // 只在点击游戏入口时替换第一次随机数,保证开局堆和后续逻辑仍是随机的 + useEffect(() => { + if (nextGame === 'random') return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if ( + !(target instanceof Element) || + !target.closest('[data-component="waiting-minigame-start"]') + ) { + return; + } + const original = Math.random; + let used = false; + Math.random = () => { + if (used) return original(); + used = true; + Math.random = original; + return nextGame === 'tetris' ? 0 : 0.999_999; + }; + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => + document.removeEventListener('pointerdown', onPointerDown, true); + }, [nextGame]); + + return ( + +
+
+ Waiting minigame harness + + + + + + + Next game: + {(['random', 'tetris', 'snake'] as const).map((kind) => ( + + ))} + + + {located || 'Fake task · local preview'} + +
+
+
+ {open && ( + setOpen(false)} + activity={activity} + tokenUsage={tokenUsage} + entryDelayMs={entryDelayMs} + gamesEnabled={gamesEnabled} + onSelectActivity={setLocated} + /> + )} +
+ + Waiting for a reply + + (12s · ↓ 3 tokens · esc to cancel) + + + setOpen((value) => !value)} + /> + +
+
+ Processing. New messages will be queued. +
+
+
+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/web-shell/client/e2e/waiting-minigame.spec.ts b/packages/web-shell/client/e2e/waiting-minigame.spec.ts new file mode 100644 index 00000000000..cdab3ea1ed4 --- /dev/null +++ b/packages/web-shell/client/e2e/waiting-minigame.spec.ts @@ -0,0 +1,289 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { + assistantTextEvent, + createWebShellDaemonScenario, + installMockDaemon, + replayCompleteEvent, + turnCompleteEvent, + type MockDaemonController, + type WebShellDaemonScenario, +} from './utils/mockDaemon'; + +async function installScenario( + page: Page, + scenario: WebShellDaemonScenario, + testInfo: TestInfo, +): Promise { + return installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); +} + +async function gotoSession( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, +): Promise { + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect( + page.locator('[data-web-shell-root]:not([data-web-shell-gate])'), + ).toBeVisible(); + const connection = await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: connection.sessionId, + replayedCount: scenario.events.length, + }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); +} + +async function startWaitingCard( + page: Page, + daemon: MockDaemonController, + prompt: string, +): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.type(prompt); + await page.locator('[data-web-shell-composer-submit]').click(); + await expect.poll(() => daemon.promptRequests().length).toBe(1); + await daemon.sendEvent(assistantTextEvent('Working on it', { id: 10 })); + await expect(page.locator('[data-web-shell-message-list]')).toContainText( + 'Working on it', + ); + const trigger = page.locator('[data-component="waiting-minigame-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); + const game = page.locator('[data-component="waiting-minigame"]'); + await expect(game).toBeVisible(); +} + +async function forceRandom(page: Page, value: number): Promise { + await page.evaluate((forced) => { + Math.random = () => forced; + }, value); +} + +async function waitForGame(game: ReturnType): Promise { + await expect( + game.locator('[role="img"][aria-label="Qwen Code"]'), + ).toHaveCount(0); +} + +test('shows the activity heatmap, moves the intro into Tetris and closes when the turn ends @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + await page.addInitScript(() => { + window.localStorage.setItem('qwen-code-web-shell-waiting-games', '1'); + }); + await gotoSession(page, scenario, daemon); + + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.type('Ping while waiting'); + await page.locator('[data-web-shell-composer-submit]').click(); + await expect.poll(() => daemon.promptRequests().length).toBe(1); + + await daemon.sendEvent(assistantTextEvent('Working on it', { id: 10 })); + await expect(page.locator('[data-web-shell-message-list]')).toContainText( + 'Working on it', + ); + + const trigger = page.locator('[data-component="waiting-minigame-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-trigger.png'), + }); + + await trigger.click(); + const game = page.locator('[data-component="waiting-minigame"]'); + await expect(game).toBeVisible(); + await expect(game).toContainText('Q&A activity'); + await expect(game).toContainText('Activity'); + await expect(game.locator('[data-level]')).toHaveCount(44 * 7); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await game.locator('[data-level]:not([data-empty="true"])').first().hover(); + const tooltip = game.locator('[role="tooltip"]'); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('Q&A'); + await expect(tooltip).toContainText('Click to locate'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-heatmap-tooltip.png'), + }); + + const start = game.locator('[data-component="waiting-minigame-start"]'); + await expect(start).toHaveCount(0); + await expect(start).toBeVisible({ timeout: 8_000 }); + await page.waitForTimeout(250); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-heatmap.png'), + }); + + await forceRandom(page, 0.1); + await start.click(); + const intro = game.locator('[role="img"][aria-label="Qwen Code"]'); + await expect(intro).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-intro.png'), + }); + await expect(intro).toHaveAttribute('data-settling', 'true'); + await page.waitForTimeout(200); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-flight.png'), + }); + await expect(intro).toHaveAttribute('data-landed', 'true'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-landed.png'), + }); + + await waitForGame(game); + await expect(game.locator('[data-state]')).toHaveCount(44 * 7); + await expect(game).toContainText('Score 0'); + await expect(game).toContainText('clear a column +50'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-board.png'), + }); + + await game.focus(); + await page.keyboard.press('Space'); + await expect(game).toContainText('Paused'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-paused.png'), + }); + await page.keyboard.press('Space'); + await expect(game).not.toContainText('Paused'); + + await page.keyboard.press('Escape'); + await expect(game).toHaveCount(0); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await daemon.sendEvent(turnCompleteEvent('prompt-e2e', { id: 11 })); + await expect(trigger).toHaveCount(0); +}); + +test('keeps the game entry off without the games config', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + await gotoSession(page, scenario, daemon); + await startWaitingCard(page, daemon, 'Activity only'); + const game = page.locator('[data-component="waiting-minigame"]'); + + await expect(game).toContainText('Q&A activity'); + await expect( + game.locator('[data-component="waiting-minigame-start"]'), + ).toHaveCount(0); + await page.waitForTimeout(6_000); + await expect( + game.locator('[data-component="waiting-minigame-start"]'), + ).toHaveCount(0); + await game.getByRole('tab', { name: 'Usage' }).click(); + await expect(game).toContainText('Token usage'); +}); + +test('keeps the intro blocks still and starts Snake with an apple', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + await page.addInitScript(() => { + window.localStorage.setItem('qwen-code-web-shell-waiting-games', '1'); + }); + await gotoSession(page, scenario, daemon); + await startWaitingCard(page, daemon, 'Snake while waiting'); + const game = page.locator('[data-component="waiting-minigame"]'); + + const start = game.locator('[data-component="waiting-minigame-start"]'); + await expect(start).toBeVisible({ timeout: 8_000 }); + await forceRandom(page, 0.9); + await start.click(); + const intro = game.locator('[role="img"][aria-label="Qwen Code"]'); + await expect(intro).toBeVisible(); + await page.waitForTimeout(700); + await expect(intro).not.toHaveAttribute('data-settling', 'true'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-snake-intro.png'), + }); + + await waitForGame(game); + await expect(game).toContainText('Arrows / WASD turn'); + await expect(game.locator('[data-state="3"]')).toContainText('🍎'); + await game.focus(); + await page.keyboard.press('Space'); + await expect(game).toContainText('Paused'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-snake.png'), + }); +}); + +test('renders the waiting card in dark theme and Chinese', async ({ + page, +}, testInfo) => { + await page.addInitScript(() => { + window.localStorage.setItem('qwen-code-web-shell-theme', 'dark'); + window.localStorage.setItem('qwen-code-web-shell-language', 'zh-CN'); + window.localStorage.setItem('qwen-code-web-shell-waiting-games', '1'); + }); + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + await gotoSession(page, scenario, daemon); + await startWaitingCard(page, daemon, 'Wait game'); + const game = page.locator('[data-component="waiting-minigame"]'); + + await expect(game).toContainText('问答热度'); + const start = game.locator('[data-component="waiting-minigame-start"]'); + await expect(start).toBeVisible({ timeout: 8_000 }); + await page.waitForTimeout(250); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-dark-zh-heatmap.png'), + }); + + await forceRandom(page, 0.1); + await start.click(); + await waitForGame(game); + await expect(game).toContainText('得分 0'); + await page.screenshot({ + path: testInfo.outputPath('waiting-minigame-dark-zh.png'), + }); + await game.screenshot({ + path: testInfo.outputPath('waiting-minigame-dark-zh-card.png'), + }); +}); + +test('keeps calendar days clickable next to the game entry with reduced motion', async ({ + page, +}, testInfo) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript(() => { + window.localStorage.setItem('qwen-code-web-shell-waiting-games', '1'); + }); + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + await gotoSession(page, scenario, daemon); + await forceRandom(page, 0); + await startWaitingCard(page, daemon, 'Reduced motion'); + const game = page.locator('[data-component="waiting-minigame"]'); + + const start = game.locator('[data-component="waiting-minigame-start"]'); + await expect(start).toBeVisible({ timeout: 8_000 }); + + // The entry's forgiving hit area must not cover the calendar. + const populated = game + .locator('[data-level]:not([data-empty="true"])') + .first(); + await populated.hover(); + await expect(game.locator('[role="tooltip"]')).toBeVisible(); + await populated.click(); + await expect( + game.locator('[role="img"][aria-label="Qwen Code"]'), + ).toHaveCount(0); + await expect(game.locator('[data-state]')).toHaveCount(0); + await expect(start).toBeVisible(); +}); diff --git a/packages/web-shell/client/i18n.test.ts b/packages/web-shell/client/i18n.test.ts index b4679b6a8e9..e9e6269f807 100644 --- a/packages/web-shell/client/i18n.test.ts +++ b/packages/web-shell/client/i18n.test.ts @@ -129,6 +129,49 @@ describe('web-shell i18n catalog', () => { ); }); + it('keeps the waiting minigame copy available in both locales', () => { + for (const language of ['en', 'zh-CN'] as const) { + const t = getTranslator(language); + for (const key of [ + 'waitingGame.title', + 'waitingGame.openActivity', + 'waitingGame.activityTitle', + 'waitingGame.usageTitle', + 'waitingGame.startGame', + 'waitingGame.backToHeatmap', + 'waitingGame.heatmap', + 'waitingGame.tokens', + 'waitingGame.jumpToMessage', + 'waitingGame.tokenTotal', + 'waitingGame.tokenInput', + 'waitingGame.tokenOutput', + 'waitingGame.tokenCached', + 'waitingGame.tokenContext', + 'waitingGame.tokenEmpty', + 'waitingGame.snakeControls', + 'waitingGame.collapse', + 'waitingGame.region', + 'waitingGame.controls', + 'waitingGame.paused', + 'waitingGame.pausedHint', + 'waitingGame.gameOver', + 'waitingGame.restart', + 'waitingGame.mute', + 'waitingGame.unmute', + ]) { + expect(t(key)).not.toBe(key); + } + } + const zh = getTranslator('zh-CN'); + expect(zh('waitingGame.heatmap')).toBe('热力图'); + expect(zh('waitingGame.activityTitle')).toBe('问答热度'); + expect(zh('waitingGame.openActivity')).toBe('打开任务热度'); + expect(zh('waitingGame.score', { score: 100 })).toBe('得分 100'); + expect(zh('waitingGame.cleared', { count: 2, score: 100 })).toBe( + '消除 2 列 · +100', + ); + }); + it('keeps task cancellation and elapsed-time copy available in both locales', () => { const en = getTranslator('en'); const zh = getTranslator('zh-CN'); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 8127573ce36..ad55d3e041e 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -4018,6 +4018,37 @@ const EN: Messages = { 'stream.cancel': 'esc to cancel', 'stream.cancelArmed': 'Press Esc again to stop', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, + 'waitingGame.title': 'Juust Relax', + 'waitingGame.activityTitle': 'Q&A activity', + 'waitingGame.usageTitle': 'Token usage', + 'waitingGame.openActivity': 'Open task activity', + 'waitingGame.startGame': 'Start a random game', + 'waitingGame.backToHeatmap': 'Back to activity', + 'waitingGame.heatmap': 'Activity', + 'waitingGame.tokens': 'Usage', + 'waitingGame.heatmapTooltip': (v) => + `${v?.time ?? ''} · ${v?.count ?? 0} Q&A`, + 'waitingGame.jumpToMessage': 'Click to locate', + 'waitingGame.tokenTotal': 'Total tokens', + 'waitingGame.tokenInput': 'Input', + 'waitingGame.tokenOutput': 'Output', + 'waitingGame.tokenCached': 'Cached', + 'waitingGame.tokenContext': 'Context', + 'waitingGame.tokenEmpty': 'No token data yet', + 'waitingGame.snakeControls': 'Arrows / WASD turn · Space pause · Esc close', + 'waitingGame.collapse': 'Collapse', + 'waitingGame.region': 'Waiting minigame', + 'waitingGame.score': (v) => `Score ${v?.score ?? 0}`, + 'waitingGame.best': (v) => `Best ${v?.score ?? 0}`, + 'waitingGame.controls': + '↑↓ move · → speed up · ← rotate · Space pause/resume · clear a column +50', + 'waitingGame.paused': 'Paused', + 'waitingGame.pausedHint': 'Press Space to resume', + 'waitingGame.gameOver': 'Game over', + 'waitingGame.restart': 'Play again', + 'waitingGame.cleared': (v) => `Cleared ${v?.count ?? 0} · +${v?.score ?? 0}`, + 'waitingGame.mute': 'Mute game sound', + 'waitingGame.unmute': 'Unmute game sound', 'theme.current': (v) => `current: ${v?.theme ?? ''}`, 'theme.auto': 'Auto', 'theme.dark': 'Dark', @@ -8737,6 +8768,37 @@ const ZH: Messages = { 'status.shortcuts': '? 查看快捷键', 'stream.cancel': 'esc 取消', 'stream.cancelArmed': '再按一次 Esc 停止', + 'waitingGame.title': 'Juust Relax', + 'waitingGame.activityTitle': '问答热度', + 'waitingGame.usageTitle': 'Token 消耗', + 'waitingGame.openActivity': '打开任务热度', + 'waitingGame.startGame': '随机开始游戏', + 'waitingGame.backToHeatmap': '返回热力图', + 'waitingGame.heatmap': '热力图', + 'waitingGame.tokens': '消耗', + 'waitingGame.heatmapTooltip': (v) => + `${v?.time ?? ''} · ${v?.count ?? 0} 条问答`, + 'waitingGame.jumpToMessage': '点击定位', + 'waitingGame.tokenTotal': '总 Token', + 'waitingGame.tokenInput': '输入', + 'waitingGame.tokenOutput': '输出', + 'waitingGame.tokenCached': '缓存', + 'waitingGame.tokenContext': '上下文', + 'waitingGame.tokenEmpty': '暂无 Token 数据', + 'waitingGame.snakeControls': '方向键 / WASD 转向 · 空格暂停 · Esc 收起', + 'waitingGame.collapse': '收起', + 'waitingGame.region': '等待小游戏', + 'waitingGame.score': (v) => `得分 ${v?.score ?? 0}`, + 'waitingGame.best': (v) => `最高 ${v?.score ?? 0}`, + 'waitingGame.controls': + '↑↓ 移动 · → 加速 · ← 旋转 · 空格 暂停/继续 · 满列消除 +50', + 'waitingGame.paused': '已暂停', + 'waitingGame.pausedHint': '按空格继续', + 'waitingGame.gameOver': '游戏结束', + 'waitingGame.restart': '再来一局', + 'waitingGame.cleared': (v) => `消除 ${v?.count ?? 0} 列 · +${v?.score ?? 0}`, + 'waitingGame.mute': '关闭游戏音效', + 'waitingGame.unmute': '开启游戏音效', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, 'theme.current': (v) => `当前:${v?.theme ?? ''}`, 'theme.auto': '自动', diff --git a/packages/web-shell/client/utils/waitingActivity.test.ts b/packages/web-shell/client/utils/waitingActivity.test.ts new file mode 100644 index 00000000000..d5dfa077d6f --- /dev/null +++ b/packages/web-shell/client/utils/waitingActivity.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildWaitingActivity, + buildWaitingTokenUsage, + WAITING_ACTIVITY_CELLS, +} from './waitingActivity'; + +// Wednesday, November 4, 2026 (local time), so the current week has future days. +const NOW = new Date(2026, 10, 4, 12, 0, 0).getTime(); +const at = (month: number, day: number, hour = 10, year = 2026): number => + new Date(year, month, day, hour).getTime(); +const startOfDay = (value: number): number => { + const date = new Date(value); + date.setHours(0, 0, 0, 0); + return date.getTime(); +}; + +describe('waiting activity calendar', () => { + it('returns a fixed 44-week calendar with empty days', () => { + const cells = buildWaitingActivity([], { now: NOW }); + expect(cells).toHaveLength(WAITING_ACTIVITY_CELLS); + expect(cells.every((cell) => cell.count === 0)).toBe(true); + expect(cells.every((cell) => cell.future || cell.startAt > 0)).toBe(true); + expect(cells.filter((cell) => cell.future)).toHaveLength(4); + expect(new Date(cells[0]!.startAt).getHours()).toBe(0); + expect(cells[0]!.endAt).toBe(cells[1]!.startAt); + }); + + it('groups messages into fixed local days and anchors the first user', () => { + const cells = buildWaitingActivity( + [ + { id: 'assistant-old', role: 'assistant', timestamp: at(9, 28) }, + { id: 'user-old', role: 'user', timestamp: at(9, 28, 11) }, + { id: 'user-2-old', role: 'user', timestamp: at(9, 28, 12) }, + { id: 'assistant-today', role: 'assistant', timestamp: at(10, 4) }, + { id: 'user-today', role: 'user', timestamp: at(10, 4, 11) }, + ], + { weeks: 2, now: NOW }, + ); + expect(cells).toHaveLength(14); + const oldDay = cells.find((cell) => cell.startAt === startOfDay(at(9, 28))); + const today = cells.find((cell) => cell.startAt === startOfDay(at(10, 4))); + expect(oldDay).toMatchObject({ count: 3, messageId: 'user-old' }); + expect(today).toMatchObject({ count: 2, messageId: 'user-today' }); + expect(today?.future).toBeUndefined(); + expect(cells.filter((cell) => cell.count > 0)).toHaveLength(2); + }); + + it('ignores messages outside the calendar window', () => { + const cells = buildWaitingActivity( + [ + { id: 'too-old', role: 'user', timestamp: at(8, 1) }, + { id: 'too-new', role: 'user', timestamp: at(10, 5) }, + ], + { weeks: 2, now: NOW }, + ); + expect(cells.every((cell) => cell.count === 0)).toBe(true); + expect(cells.every((cell) => cell.messageId === undefined)).toBe(true); + }); + + it('marks days after today as future placeholders', () => { + const cells = buildWaitingActivity([], { weeks: 1, now: NOW }); + expect(cells.slice(0, 3).every((cell) => !cell.future)).toBe(true); + expect(cells.slice(3).every((cell) => cell.future)).toBe(true); + }); + + it('sums assistant usage and falls back to the latest usage event', () => { + const summed = buildWaitingTokenUsage( + [ + { + role: 'assistant', + usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 40 }, + }, + { + role: 'assistant', + usage: { inputTokens: 50, outputTokens: 10 }, + }, + ], + {}, + 900, + 1_000, + ); + expect(summed).toEqual({ + input: 150, + output: 30, + cached: 40, + context: 900, + contextLimit: 1_000, + }); + + const fallback = buildWaitingTokenUsage([{ role: 'user' }], { + inputTokens: 7, + outputTokens: 3, + cachedTokens: 1, + }); + expect(fallback).toEqual({ input: 7, output: 3, cached: 1 }); + + const connectionFallback = buildWaitingTokenUsage([{ role: 'user' }], { + inputTokens: 100, + outputTokens: 20, + cachedReadTokens: 50, + }); + expect(connectionFallback).toEqual({ input: 100, output: 20, cached: 50 }); + }); +}); diff --git a/packages/web-shell/client/utils/waitingActivity.ts b/packages/web-shell/client/utils/waitingActivity.ts new file mode 100644 index 00000000000..c266a60f90d --- /dev/null +++ b/packages/web-shell/client/utils/waitingActivity.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Summaries for the waiting card: the task's daily Q&A calendar and token usage. + +export const WAITING_ACTIVITY_WEEKS = 44; +export const WAITING_ACTIVITY_DAYS = 7; +export const WAITING_ACTIVITY_CELLS = + WAITING_ACTIVITY_WEEKS * WAITING_ACTIVITY_DAYS; + +export interface WaitingActivityDay { + /** Local midnight at the start of the day. */ + startAt: number; + /** Local midnight at the start of the next day. */ + endAt: number; + count: number; + /** First message that day, preferring the user question. */ + messageId?: string; + /** Calendar placeholders after today, rendered without interaction. */ + future?: boolean; +} + +export interface WaitingActivityMessage { + id?: string; + role?: string; + timestamp?: number; + usage?: { + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + }; +} + +export interface WaitingActivityOptions { + weeks?: number; + /** Test seam for the local "today" anchor. */ + now?: number; +} + +export interface WaitingTokenUsage { + input: number; + output: number; + cached: number; + context?: number; + contextLimit?: number; +} + +export interface WaitingTokenUsageFallback { + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + /** The connection-level usage object keeps the daemon's wire field name. */ + cachedReadTokens?: number; +} + +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function startOfLocalDay(value: number): number { + const date = new Date(value); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +function addLocalDays(value: number, days: number): number { + // setDate keeps local midnight stable across daylight-saving changes. + const date = new Date(value); + date.setDate(date.getDate() + days); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +function mondayOffset(value: number): number { + return (new Date(value).getDay() + 6) % 7; +} + +/** + * Groups user and assistant messages into a fixed 44-week calendar. Each cell + * is one local day and columns are Monday-to-Sunday weeks. + */ +export function buildWaitingActivity( + messages: readonly WaitingActivityMessage[], + options: WaitingActivityOptions = {}, +): WaitingActivityDay[] { + const weeks = Math.max( + 1, + Math.floor(options.weeks ?? WAITING_ACTIVITY_WEEKS), + ); + const cellCount = weeks * WAITING_ACTIVITY_DAYS; + const today = startOfLocalDay(options.now ?? Date.now()); + const gridStart = addLocalDays( + today, + -(mondayOffset(today) + (weeks - 1) * WAITING_ACTIVITY_DAYS), + ); + const cells: WaitingActivityDay[] = []; + const indexByDay = new Map(); + for (let index = 0; index < cellCount; index++) { + const dayStart = addLocalDays(gridStart, index); + indexByDay.set(dayStart, index); + cells.push({ + startAt: dayStart, + endAt: addLocalDays(dayStart, 1), + count: 0, + ...(dayStart > today ? { future: true } : {}), + }); + } + + const anchors: Array<{ id: string; role: string } | undefined> = []; + for (const message of messages) { + if ( + (message.role !== 'user' && message.role !== 'assistant') || + !isTimestamp(message.timestamp) + ) { + continue; + } + const index = indexByDay.get(startOfLocalDay(message.timestamp)); + if (index === undefined || cells[index]!.future) continue; + cells[index]!.count += 1; + const anchor = anchors[index]; + if ( + message.id && + (!anchor || (message.role === 'user' && anchor.role !== 'user')) + ) { + anchors[index] = { id: message.id, role: message.role }; + } + } + anchors.forEach((anchor, index) => { + if (anchor) cells[index]!.messageId = anchor.id; + }); + return cells; +} + +/** Sums assistant usage across the task; falls back to the latest usage event. */ +export function buildWaitingTokenUsage( + messages: readonly WaitingActivityMessage[], + fallback: WaitingTokenUsageFallback = {}, + context?: number, + contextLimit?: number, +): WaitingTokenUsage { + let input = 0; + let output = 0; + let cached = 0; + for (const message of messages) { + if (message.role !== 'assistant' || !message.usage) continue; + input += Math.max(0, message.usage.inputTokens ?? 0); + output += Math.max(0, message.usage.outputTokens ?? 0); + cached += Math.max(0, message.usage.cachedTokens ?? 0); + } + if (input === 0 && output === 0) { + input = Math.max(0, fallback.inputTokens ?? 0); + output = Math.max(0, fallback.outputTokens ?? 0); + cached = Math.max( + 0, + fallback.cachedTokens ?? fallback.cachedReadTokens ?? 0, + ); + } + return { + input, + output, + cached, + ...(context !== undefined ? { context: Math.max(0, context) } : {}), + ...(contextLimit !== undefined + ? { contextLimit: Math.max(0, contextLimit) } + : {}), + }; +} diff --git a/packages/web-shell/client/utils/waitingGamesConfig.test.ts b/packages/web-shell/client/utils/waitingGamesConfig.test.ts new file mode 100644 index 00000000000..09147a60da8 --- /dev/null +++ b/packages/web-shell/client/utils/waitingGamesConfig.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { + readWaitingGamesEnabled, + WAITING_GAMES_STORAGE_KEY, +} from './waitingGamesConfig'; + +type WaitingGamesWindow = Window & { + __QWEN_CODE_WAITING_GAMES__?: boolean; +}; + +const reset = () => { + window.localStorage.clear(); + window.history.replaceState({}, '', '/'); + delete (window as WaitingGamesWindow).__QWEN_CODE_WAITING_GAMES__; +}; + +afterEach(reset); + +describe('waiting games configuration', () => { + it('is off by default', () => { + expect(readWaitingGamesEnabled()).toBe(false); + }); + + it('reads the persisted storage flag', () => { + window.localStorage.setItem(WAITING_GAMES_STORAGE_KEY, '1'); + expect(readWaitingGamesEnabled()).toBe(true); + window.localStorage.setItem(WAITING_GAMES_STORAGE_KEY, '0'); + expect(readWaitingGamesEnabled()).toBe(false); + }); + + it('reads the URL flag and lets it override storage', () => { + window.localStorage.setItem(WAITING_GAMES_STORAGE_KEY, '1'); + window.history.replaceState({}, '', '/?waitingGames=0'); + expect(readWaitingGamesEnabled()).toBe(false); + window.history.replaceState({}, '', '/?waitingGames=true'); + expect(readWaitingGamesEnabled()).toBe(true); + window.history.replaceState({}, '', '/?waiting-games=1'); + expect(readWaitingGamesEnabled()).toBe(true); + }); + + it('reads the host-injected global', () => { + (window as WaitingGamesWindow).__QWEN_CODE_WAITING_GAMES__ = true; + expect(readWaitingGamesEnabled()).toBe(true); + (window as WaitingGamesWindow).__QWEN_CODE_WAITING_GAMES__ = false; + expect(readWaitingGamesEnabled()).toBe(false); + }); +}); diff --git a/packages/web-shell/client/utils/waitingGamesConfig.ts b/packages/web-shell/client/utils/waitingGamesConfig.ts new file mode 100644 index 00000000000..08ab4bee891 --- /dev/null +++ b/packages/web-shell/client/utils/waitingGamesConfig.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The waiting card's game entry is opt-in. The activity and token views stay +// available without it. +export const WAITING_GAMES_STORAGE_KEY = 'qwen-code-web-shell-waiting-games'; + +type WaitingGamesWindow = Window & { + __QWEN_CODE_WAITING_GAMES__?: boolean; +}; + +export function readWaitingGamesEnabled(): boolean { + if (typeof window === 'undefined') return false; + try { + const params = new URLSearchParams(window.location.search); + const param = params.get('waitingGames') ?? params.get('waiting-games'); + if (param !== null) { + return param === '1' || param.toLowerCase() === 'true'; + } + if ((window as WaitingGamesWindow).__QWEN_CODE_WAITING_GAMES__ === true) { + return true; + } + return window.localStorage.getItem(WAITING_GAMES_STORAGE_KEY) === '1'; + } catch { + return false; + } +} diff --git a/packages/web-shell/client/utils/waitingMinigame.test.ts b/packages/web-shell/client/utils/waitingMinigame.test.ts new file mode 100644 index 00000000000..8ba13598a39 --- /dev/null +++ b/packages/web-shell/client/utils/waitingMinigame.test.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + WAITING_MINIGAME_CLEAR_SCORE, + WAITING_MINIGAME_COLS, + WAITING_MINIGAME_ROWS, + buildWaitingMinigameStartCluster, + createWaitingMinigame, + dropWaitingMinigamePiece, + moveWaitingMinigamePiece, + restartWaitingMinigame, + rotateWaitingMinigamePiece, + setWaitingMinigamePaused, + tickWaitingMinigame, + toggleWaitingMinigamePause, + waitingMinigameRenderGrid, + waitingMinigameShape, + waitingMinigameTickMs, + type WaitingMinigamePiece, + type WaitingMinigameState, +} from './waitingMinigame'; + +const firstPiece = (): number => 0; +const piece = (state: WaitingMinigameState) => { + if (!state.piece) throw new Error('piece missing'); + return state.piece; +}; +const advance = (state: WaitingMinigameState, ticks: number) => { + let current = state; + for (let index = 0; index < ticks; index++) { + current = tickWaitingMinigame(current, firstPiece); + } + return current; +}; + +describe('waiting minigame engine', () => { + it('creates a 7-row wide board with a spawned piece', () => { + const state = createWaitingMinigame({ random: firstPiece }); + expect(state.rows).toBe(WAITING_MINIGAME_ROWS); + expect(state.cols).toBe(WAITING_MINIGAME_COLS); + expect(state.board).toHaveLength( + WAITING_MINIGAME_ROWS * WAITING_MINIGAME_COLS, + ); + expect(state.board.every((cell) => cell === 0)).toBe(true); + expect(state.piece).toEqual({ + type: 'I', + row: 0, + col: 0, + rotation: 0, + }); + expect(state.score).toBe(0); + expect(state.cleared).toBe(0); + expect(state.over).toBe(false); + expect(state.paused).toBe(false); + }); + + it('moves the piece one column per gravity tick', () => { + const state = createWaitingMinigame({ + rows: 3, + cols: 6, + random: firstPiece, + }); + const next = tickWaitingMinigame(state, firstPiece); + expect(piece(next).col).toBe(1); + const third = tickWaitingMinigame(next, firstPiece); + expect(piece(third).col).toBe(2); + }); + + it('keeps vertical moves inside the board and keeps blocked moves stable', () => { + const state = createWaitingMinigame({ + rows: 3, + cols: 6, + random: firstPiece, + }); + const down = moveWaitingMinigamePiece(state, 2); + expect(piece(down).row).toBe(2); + expect(moveWaitingMinigamePiece(down, 1)).toBe(down); + const up = moveWaitingMinigamePiece(down, -2); + expect(piece(up).row).toBe(0); + expect(moveWaitingMinigamePiece(up, -1)).toBe(up); + }); + + it('ignores movement and gravity while paused', () => { + const state = setWaitingMinigamePaused( + createWaitingMinigame({ rows: 3, cols: 6, random: firstPiece }), + true, + ); + expect(moveWaitingMinigamePiece(state, 1)).toBe(state); + expect(tickWaitingMinigame(state, firstPiece)).toBe(state); + expect(dropWaitingMinigamePiece(state, firstPiece)).toBe(state); + expect(rotateWaitingMinigamePiece(state)).toBe(state); + }); + + it('kicks a rotated piece back into the row range', () => { + let state = createWaitingMinigame({ + rows: 7, + cols: 10, + random: firstPiece, + }); + for (let index = 0; index < 6; index++) { + state = moveWaitingMinigamePiece(state, 1); + } + expect(piece(state).row).toBe(6); + const rotated = rotateWaitingMinigamePiece(state); + expect(piece(rotated).rotation).toBe(1); + expect(piece(rotated).row).toBe(3); + }); + + it('keeps rotation inside the board at the right edge', () => { + let state = createWaitingMinigame({ + rows: 7, + cols: 6, + random: firstPiece, + }); + for (let index = 0; index < 2; index++) { + state = tickWaitingMinigame(state, firstPiece); + } + expect(piece(state).col).toBe(2); + const rotated = rotateWaitingMinigamePiece(state); + expect(piece(rotated).rotation).toBe(1); + expect(piece(rotated).col).toBe(2); + expect(piece(rotated).col + 1).toBeLessThanOrEqual(rotated.cols); + }); + + it('locks, clears full columns and scores 50 per column', () => { + let state = createWaitingMinigame({ + rows: 2, + cols: 6, + random: firstPiece, + }); + state = advance(state, 3); + expect(state.score).toBe(0); + expect(Array.from(state.board)).toEqual([ + 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + ]); + expect(piece(state).row).toBe(1); + + state = advance(state, 3); + expect(state.score).toBe(4 * WAITING_MINIGAME_CLEAR_SCORE); + expect(state.cleared).toBe(4); + expect(Array.from(state.board)).toEqual(new Array(12).fill(0)); + expect(state.piece).not.toBeNull(); + expect(state.over).toBe(false); + }); + + it('shifts remaining blocks left after clearing a column', () => { + const base = createWaitingMinigame({ + rows: 3, + cols: 4, + random: firstPiece, + }); + const board = new Uint8Array(12); + board[0 * 4 + 0] = 1; + board[1 * 4 + 0] = 1; + const state: WaitingMinigameState = { + ...base, + board, + piece: { + type: 'I', + row: 2, + col: 0, + rotation: 0, + } satisfies WaitingMinigamePiece, + }; + const next = tickWaitingMinigame(state, firstPiece); + expect(next.cleared).toBe(1); + expect(next.score).toBe(WAITING_MINIGAME_CLEAR_SCORE); + expect(Array.from(next.board)).toEqual([ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + ]); + }); + + it('ends the game when no spawn position is available', () => { + const state = createWaitingMinigame({ + rows: 1, + cols: 3, + random: firstPiece, + }); + expect(state.over).toBe(true); + expect(state.piece).toBeNull(); + expect(tickWaitingMinigame(state, firstPiece)).toBe(state); + expect(setWaitingMinigamePaused(state, true)).toBe(state); + }); + + it('toggles pause only while running', () => { + const state = createWaitingMinigame({ + rows: 3, + cols: 6, + random: firstPiece, + }); + const paused = toggleWaitingMinigamePause(state); + expect(paused.paused).toBe(true); + expect(toggleWaitingMinigamePause(paused).paused).toBe(false); + expect(setWaitingMinigamePaused(paused, true)).toBe(paused); + }); + + it('restarts with the same dimensions and a clean board', () => { + const state = createWaitingMinigame({ + rows: 5, + cols: 9, + random: firstPiece, + }); + const restart = restartWaitingMinigame( + { ...state, score: 300, cleared: 6 }, + firstPiece, + ); + expect(restart.rows).toBe(5); + expect(restart.cols).toBe(9); + expect(restart.score).toBe(0); + expect(restart.cleared).toBe(0); + expect(restart.board.every((cell) => cell === 0)).toBe(true); + expect(restart.piece).not.toBeNull(); + }); + + it('accelerates with cleared columns and stops at the floor', () => { + expect(waitingMinigameTickMs(0)).toBe(140); + expect(waitingMinigameTickMs(5)).toBe(110); + expect(waitingMinigameTickMs(10)).toBe(80); + expect(waitingMinigameTickMs(1_000)).toBe(60); + expect(waitingMinigameTickMs(-3)).toBe(140); + }); + + it('separates settled blocks from the active piece in the render grid', () => { + const state = createWaitingMinigame({ + rows: 2, + cols: 4, + random: firstPiece, + }); + const grid = waitingMinigameRenderGrid(state); + expect(Array.from(grid.slice(0, 4))).toEqual([2, 2, 2, 2]); + expect(Array.from(grid.slice(4))).toEqual([0, 0, 0, 0]); + }); + + it('pre-fills the intro start cluster before the first piece spawns', () => { + const cluster = buildWaitingMinigameStartCluster(firstPiece); + expect(cluster).toHaveLength(12); + expect( + cluster.every( + (index) => index % WAITING_MINIGAME_COLS >= WAITING_MINIGAME_COLS - 4, + ), + ).toBe(true); + const state = createWaitingMinigame({ + random: firstPiece, + initialSettled: cluster, + }); + for (const index of cluster) { + expect(state.board[index]).toBe(1); + } + const grid = waitingMinigameRenderGrid(state); + expect(Array.from(grid.slice(0, 4))).toEqual([2, 2, 2, 2]); + }); + + it('exposes four normalized rotations for every piece', () => { + for (const type of ['I', 'O', 'T', 'S', 'Z', 'J', 'L'] as const) { + for (let rotation = 0; rotation < 4; rotation++) { + const offsets = waitingMinigameShape(type, rotation); + expect(offsets).toHaveLength(4); + expect(Math.min(...offsets.map(([row]) => row))).toBe(0); + expect(Math.min(...offsets.map(([, col]) => col))).toBe(0); + } + expect(waitingMinigameShape(type, 4)).toBe(waitingMinigameShape(type, 0)); + } + }); +}); diff --git a/packages/web-shell/client/utils/waitingMinigame.ts b/packages/web-shell/client/utils/waitingMinigame.ts new file mode 100644 index 00000000000..4e50f1ed8cc --- /dev/null +++ b/packages/web-shell/client/utils/waitingMinigame.ts @@ -0,0 +1,387 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Horizontal Tetris for the streaming wait indicator: gravity falls toward +// increasing columns, and a column clears once every row in it is filled. + +export const WAITING_MINIGAME_ROWS = 7; +export const WAITING_MINIGAME_COLS = 44; +export const WAITING_MINIGAME_CLEAR_SCORE = 50; + +export type WaitingMinigamePieceType = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'; + +export interface WaitingMinigamePiece { + type: WaitingMinigamePieceType; + row: number; + col: number; + rotation: number; +} + +export interface WaitingMinigameState { + rows: number; + cols: number; + board: Uint8Array; + piece: WaitingMinigamePiece | null; + score: number; + cleared: number; + over: boolean; + paused: boolean; +} + +export interface WaitingMinigameOptions { + rows?: number; + cols?: number; + random?: () => number; + /** Board indices that start settled, e.g. the random right-side cluster. */ + initialSettled?: readonly number[]; +} + +const PIECE_TYPES: readonly WaitingMinigamePieceType[] = [ + 'I', + 'O', + 'T', + 'S', + 'Z', + 'J', + 'L', +]; + +type Offset = readonly [number, number]; + +const BASE_OFFSETS: Record = { + I: [ + [1, 0], + [1, 1], + [1, 2], + [1, 3], + ], + O: [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ], + T: [ + [0, 1], + [1, 0], + [1, 1], + [1, 2], + ], + S: [ + [0, 1], + [0, 2], + [1, 0], + [1, 1], + ], + Z: [ + [0, 0], + [0, 1], + [1, 1], + [1, 2], + ], + J: [ + [0, 0], + [1, 0], + [1, 1], + [1, 2], + ], + L: [ + [0, 2], + [1, 0], + [1, 1], + [1, 2], + ], +}; + +function normalize(offsets: readonly Offset[]): readonly Offset[] { + const minRow = Math.min(...offsets.map(([row]) => row)); + const minCol = Math.min(...offsets.map(([, col]) => col)); + return offsets.map(([row, col]) => [row - minRow, col - minCol] as const); +} + +function rotate(offsets: readonly Offset[]): readonly Offset[] { + // Clockwise 90 degrees, then normalized to the top-left. Casual play does + // not need the standard SRS kick table. + return normalize(offsets.map(([row, col]) => [col, -row] as const)); +} + +const ROTATIONS = PIECE_TYPES.reduce( + (result, type) => { + const states: (readonly Offset[])[] = []; + let current = normalize(BASE_OFFSETS[type]); + for (let index = 0; index < 4; index++) { + states.push(current); + current = rotate(current); + } + result[type] = states; + return result; + }, + {} as Record, +); + +export function waitingMinigameShape( + type: WaitingMinigamePieceType, + rotation: number, +): readonly Offset[] { + const states = ROTATIONS[type]; + return states[((rotation % states.length) + states.length) % states.length]!; +} + +function maxOf(offsets: readonly Offset[], axis: 0 | 1): number { + return Math.max(...offsets.map((offset) => offset[axis])); +} + +function pickRandom(random: () => number, max: number): number { + const value = Math.floor(random() * max); + return Math.min(max - 1, Math.max(0, Number.isFinite(value) ? value : 0)); +} + +function canPlace( + board: Uint8Array, + rows: number, + cols: number, + offsets: readonly Offset[], + row: number, + col: number, +): boolean { + for (const [offsetRow, offsetCol] of offsets) { + const targetRow = row + offsetRow; + const targetCol = col + offsetCol; + if ( + targetRow < 0 || + targetRow >= rows || + targetCol < 0 || + targetCol >= cols + ) + return false; + if (board[targetRow * cols + targetCol]) return false; + } + return true; +} + +function spawned( + state: WaitingMinigameState, + random: () => number, +): WaitingMinigameState { + const type = PIECE_TYPES[pickRandom(random, PIECE_TYPES.length)]!; + const offsets = waitingMinigameShape(type, 0); + const maxRowStart = state.rows - 1 - maxOf(offsets, 0); + if (maxRowStart < 0) return { ...state, piece: null, over: true }; + const start = pickRandom(random, maxRowStart + 1); + for (let attempt = 0; attempt <= maxRowStart; attempt++) { + const row = (start + attempt) % (maxRowStart + 1); + if (canPlace(state.board, state.rows, state.cols, offsets, row, 0)) { + return { ...state, piece: { type, row, col: 0, rotation: 0 } }; + } + } + return { ...state, piece: null, over: true }; +} + +export function createWaitingMinigame( + options: WaitingMinigameOptions = {}, +): WaitingMinigameState { + const rows = Math.max(1, options.rows ?? WAITING_MINIGAME_ROWS); + const cols = Math.max(1, options.cols ?? WAITING_MINIGAME_COLS); + const board = new Uint8Array(rows * cols); + for (const index of options.initialSettled ?? []) { + if (index >= 0 && index < board.length) board[index] = 1; + } + const initial: WaitingMinigameState = { + rows, + cols, + board, + piece: null, + score: 0, + cleared: 0, + over: false, + paused: false, + }; + return spawned(initial, options.random ?? Math.random); +} + +/** + * A sparse settled cluster in the rightmost columns. The intro blocks fly to + * these cells, then the game starts with the same cluster on the board. + */ +export function buildWaitingMinigameStartCluster( + random: () => number = Math.random, + rows = WAITING_MINIGAME_ROWS, + cols = WAITING_MINIGAME_COLS, +): number[] { + const cluster: number[] = []; + const firstCol = Math.max(0, cols - 4); + for (let col = firstCol; col < cols; col++) { + const count = 3 + Math.floor(random() * 4); + const startRow = Math.floor(random() * rows); + for (let offset = 0; offset < count; offset++) { + const row = (startRow + offset) % rows; + cluster.push(row * cols + col); + } + } + return cluster; +} + +export function waitingMinigameTickMs(cleared: number): number { + return Math.max(60, 140 - Math.max(0, cleared) * 6); +} + +function moved( + state: WaitingMinigameState, + row: number, + col: number, +): WaitingMinigameState { + const piece = state.piece!; + const offsets = waitingMinigameShape(piece.type, piece.rotation); + if (!canPlace(state.board, state.rows, state.cols, offsets, row, col)) + return state; + return { ...state, piece: { ...piece, row, col } }; +} + +export function moveWaitingMinigamePiece( + state: WaitingMinigameState, + deltaRow: number, +): WaitingMinigameState { + if (state.over || state.paused || !state.piece || deltaRow === 0) + return state; + return moved(state, state.piece.row + deltaRow, state.piece.col); +} + +export function rotateWaitingMinigamePiece( + state: WaitingMinigameState, +): WaitingMinigameState { + if (state.over || state.paused || !state.piece) return state; + const piece = state.piece; + const rotation = (piece.rotation + 1) % 4; + const offsets = waitingMinigameShape(piece.type, rotation); + let col = piece.col; + const maxCol = maxOf(offsets, 1); + if (col + maxCol >= state.cols) col = state.cols - 1 - maxCol; + if (col < 0) col = 0; + for (const deltaRow of [0, -1, 1, -2, 2, -3, 3]) { + const row = piece.row + deltaRow; + if (canPlace(state.board, state.rows, state.cols, offsets, row, col)) { + return { ...state, piece: { ...piece, row, col, rotation } }; + } + } + return state; +} + +function locked( + state: WaitingMinigameState, + random: () => number, +): WaitingMinigameState { + const piece = state.piece; + if (!piece) return state; + const offsets = waitingMinigameShape(piece.type, piece.rotation); + const board = state.board.slice(); + for (const [offsetRow, offsetCol] of offsets) { + board[(piece.row + offsetRow) * state.cols + piece.col + offsetCol] = 1; + } + + const fullColumns: boolean[] = []; + let clearedColumns = 0; + for (let col = 0; col < state.cols; col++) { + let full = true; + for (let row = 0; row < state.rows; row++) { + if (!board[row * state.cols + col]) { + full = false; + break; + } + } + fullColumns.push(full); + if (full) clearedColumns++; + } + + let nextBoard = board; + if (clearedColumns > 0) { + // Remaining columns shift left; pad empty columns at the right edge. + nextBoard = new Uint8Array(state.rows * state.cols); + let target = 0; + for (let col = 0; col < state.cols; col++) { + if (fullColumns[col]) continue; + for (let row = 0; row < state.rows; row++) { + nextBoard[row * state.cols + target] = board[row * state.cols + col]; + } + target++; + } + } + + return spawned( + { + ...state, + board: nextBoard, + piece: null, + score: state.score + clearedColumns * WAITING_MINIGAME_CLEAR_SCORE, + cleared: state.cleared + clearedColumns, + }, + random, + ); +} + +/** One gravity step: move right, or lock and clear. */ +export function tickWaitingMinigame( + state: WaitingMinigameState, + random: () => number = Math.random, +): WaitingMinigameState { + if (state.over || state.paused || !state.piece) return state; + const next = moved(state, state.piece.row, state.piece.col + 1); + if (next !== state) return next; + return locked(state, random); +} + +/** Manual speed-up (ArrowRight); same lock rule as gravity. */ +export function dropWaitingMinigamePiece( + state: WaitingMinigameState, + random: () => number = Math.random, +): WaitingMinigameState { + return tickWaitingMinigame(state, random); +} + +export function setWaitingMinigamePaused( + state: WaitingMinigameState, + paused: boolean, +): WaitingMinigameState { + if (state.over || state.paused === paused) return state; + return { ...state, paused }; +} + +export function toggleWaitingMinigamePause( + state: WaitingMinigameState, +): WaitingMinigameState { + return setWaitingMinigamePaused(state, !state.paused); +} + +export function restartWaitingMinigame( + state: WaitingMinigameState, + random: () => number = Math.random, +): WaitingMinigameState { + return createWaitingMinigame({ + rows: state.rows, + cols: state.cols, + random, + }); +} + +/** 0 empty, 1 settled, 2 active piece. */ +export function waitingMinigameRenderGrid( + state: WaitingMinigameState, +): Uint8Array { + const grid = state.board.slice(); + if (state.piece && !state.over) { + const offsets = waitingMinigameShape( + state.piece.type, + state.piece.rotation, + ); + for (const [offsetRow, offsetCol] of offsets) { + const row = state.piece.row + offsetRow; + const col = state.piece.col + offsetCol; + if (row < 0 || row >= state.rows || col < 0 || col >= state.cols) + continue; + grid[row * state.cols + col] = 2; + } + } + return grid; +} diff --git a/packages/web-shell/client/utils/waitingSnake.test.ts b/packages/web-shell/client/utils/waitingSnake.test.ts new file mode 100644 index 00000000000..d1f68378797 --- /dev/null +++ b/packages/web-shell/client/utils/waitingSnake.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + WAITING_SNAKE_COLS, + WAITING_SNAKE_LENGTH, + WAITING_SNAKE_ROWS, + createWaitingSnake, + restartWaitingSnake, + setWaitingSnakePaused, + tickWaitingSnake, + toggleWaitingSnakePause, + turnWaitingSnake, + waitingSnakeRenderGrid, + waitingSnakeTickMs, + type WaitingSnakeState, +} from './waitingSnake'; + +const firstFood = (): number => 0; +const head = (state: WaitingSnakeState) => state.snake[0]!; + +describe('waiting snake engine', () => { + it('creates a short snake moving right with food off the body', () => { + const state = createWaitingSnake({ random: firstFood }); + expect(state.rows).toBe(WAITING_SNAKE_ROWS); + expect(state.cols).toBe(WAITING_SNAKE_COLS); + expect(state.snake).toHaveLength(WAITING_SNAKE_LENGTH); + expect(state.direction).toBe('right'); + expect(state.food).not.toBeNull(); + expect( + state.snake.some( + (cell) => cell.row === state.food!.row && cell.col === state.food!.col, + ), + ).toBe(false); + }); + + it('moves one cell per tick and keeps the length', () => { + const state = createWaitingSnake({ random: firstFood }); + const next = tickWaitingSnake(state, firstFood); + expect(head(next).col).toBe(head(state).col + 1); + expect(next.snake).toHaveLength(WAITING_SNAKE_LENGTH); + }); + + it('queues turns and ignores reversals', () => { + const state = createWaitingSnake({ random: firstFood }); + const reversed = turnWaitingSnake(state, 'left'); + expect(reversed).toBe(state); + const turned = turnWaitingSnake(state, 'up'); + expect(turned.pending).toEqual(['up']); + const second = turnWaitingSnake(turned, 'left'); + expect(second.pending).toEqual(['up', 'left']); + expect(turnWaitingSnake(second, 'down').pending).toEqual(['up', 'left']); + const afterUp = tickWaitingSnake(second, firstFood); + expect(afterUp.direction).toBe('up'); + expect(afterUp.pending).toEqual(['left']); + const afterLeft = tickWaitingSnake(afterUp, firstFood); + expect(afterLeft.direction).toBe('left'); + }); + + it('eats food, grows and scores', () => { + const state = createWaitingSnake({ random: firstFood }); + const ahead = { + row: head(state).row, + col: head(state).col + 1, + }; + const next = tickWaitingSnake({ ...state, food: ahead }, firstFood); + expect(next.score).toBe(1); + expect(next.snake).toHaveLength(WAITING_SNAKE_LENGTH + 1); + expect(next.over).toBe(false); + expect(next.food).not.toEqual(ahead); + }); + + it('ends the game at a wall', () => { + let state = createWaitingSnake({ rows: 3, cols: 6, random: firstFood }); + state = { ...state, food: null }; + for (let index = 0; index < 10 && !state.over; index++) { + state = tickWaitingSnake(state, firstFood); + } + expect(state.over).toBe(true); + }); + + it('ends the game when the head enters the body', () => { + const base = createWaitingSnake({ rows: 5, cols: 8, random: firstFood }); + const state: WaitingSnakeState = { + ...base, + snake: [ + { row: 2, col: 3 }, + { row: 2, col: 2 }, + { row: 1, col: 2 }, + { row: 1, col: 3 }, + { row: 1, col: 4 }, + ], + direction: 'up', + pending: [], + food: { row: 4, col: 0 }, + }; + const next = tickWaitingSnake(state, firstFood); + expect(next.over).toBe(true); + }); + + it('pauses and restarts without changing dimensions', () => { + const state = createWaitingSnake({ rows: 4, cols: 9, random: firstFood }); + const paused = toggleWaitingSnakePause(state); + expect(paused.paused).toBe(true); + expect(tickWaitingSnake(paused, firstFood)).toBe(paused); + expect(setWaitingSnakePaused(paused, false).paused).toBe(false); + const restart = restartWaitingSnake( + { ...state, score: 5, over: true }, + firstFood, + ); + expect(restart.rows).toBe(4); + expect(restart.cols).toBe(9); + expect(restart.score).toBe(0); + expect(restart.over).toBe(false); + }); + + it('renders body, head and food distinctly', () => { + const state = createWaitingSnake({ rows: 3, cols: 6, random: firstFood }); + const grid = waitingSnakeRenderGrid(state); + const headIndex = head(state).row * state.cols + head(state).col; + expect(grid[headIndex]).toBe(2); + expect(grid.filter((cell) => cell === 1)).toHaveLength( + WAITING_SNAKE_LENGTH - 1, + ); + const foodIndex = state.food!.row * state.cols + state.food!.col; + expect(grid[foodIndex]).toBe(3); + }); + + it('accelerates with score and has a floor', () => { + expect(waitingSnakeTickMs(0)).toBe(240); + expect(waitingSnakeTickMs(5)).toBe(215); + expect(waitingSnakeTickMs(100)).toBe(100); + expect(waitingSnakeTickMs(-1)).toBe(240); + }); +}); diff --git a/packages/web-shell/client/utils/waitingSnake.ts b/packages/web-shell/client/utils/waitingSnake.ts new file mode 100644 index 00000000000..a5b4e5c6f54 --- /dev/null +++ b/packages/web-shell/client/utils/waitingSnake.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Snake for the waiting card. The board is wide and short, matching the +// horizontal activity heatmap and the Tetris board. + +export const WAITING_SNAKE_ROWS = 7; +export const WAITING_SNAKE_COLS = 44; +export const WAITING_SNAKE_LENGTH = 3; + +export type WaitingSnakeDirection = 'up' | 'down' | 'left' | 'right'; + +export interface WaitingSnakeCell { + row: number; + col: number; +} + +export interface WaitingSnakeState { + rows: number; + cols: number; + /** Head first. */ + snake: readonly WaitingSnakeCell[]; + direction: WaitingSnakeDirection; + /** Directions applied one per tick; at most two are queued. */ + pending: readonly WaitingSnakeDirection[]; + food: WaitingSnakeCell | null; + score: number; + over: boolean; + paused: boolean; +} + +export interface WaitingSnakeOptions { + rows?: number; + cols?: number; + random?: () => number; +} + +const VECTORS: Record = { + up: { row: -1, col: 0 }, + down: { row: 1, col: 0 }, + left: { row: 0, col: -1 }, + right: { row: 0, col: 1 }, +}; + +const OPPOSITE: Record = { + up: 'down', + down: 'up', + left: 'right', + right: 'left', +}; + +function sameCell(a: WaitingSnakeCell, b: WaitingSnakeCell): boolean { + return a.row === b.row && a.col === b.col; +} + +function spawnFood( + rows: number, + cols: number, + snake: readonly WaitingSnakeCell[], + random: () => number, +): WaitingSnakeCell | null { + const occupied = new Set(snake.map((cell) => `${cell.row}:${cell.col}`)); + const free: WaitingSnakeCell[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + if (!occupied.has(`${row}:${col}`)) free.push({ row, col }); + } + } + if (free.length === 0) return null; + const index = Math.min( + free.length - 1, + Math.max(0, Math.floor(random() * free.length)), + ); + return free[index]!; +} + +export function createWaitingSnake( + options: WaitingSnakeOptions = {}, +): WaitingSnakeState { + const rows = Math.max(2, options.rows ?? WAITING_SNAKE_ROWS); + const cols = Math.max(4, options.cols ?? WAITING_SNAKE_COLS); + const length = Math.min(WAITING_SNAKE_LENGTH, cols - 1); + const row = Math.floor(rows / 2); + const snake: WaitingSnakeCell[] = Array.from({ length }, (_, index) => ({ + row, + col: length - 1 - index, + })); + return { + rows, + cols, + snake, + direction: 'right', + pending: [], + food: spawnFood(rows, cols, snake, options.random ?? Math.random), + score: 0, + over: false, + paused: false, + }; +} + +export function turnWaitingSnake( + state: WaitingSnakeState, + direction: WaitingSnakeDirection, +): WaitingSnakeState { + if (state.over || state.paused) return state; + const last = state.pending.at(-1) ?? state.direction; + if (direction === last || direction === OPPOSITE[last]) return state; + if (state.pending.length >= 2) return state; + return { ...state, pending: [...state.pending, direction] }; +} + +export function waitingSnakeTickMs(score: number): number { + return Math.max(100, 240 - Math.max(0, score) * 5); +} + +export function tickWaitingSnake( + state: WaitingSnakeState, + random: () => number = Math.random, +): WaitingSnakeState { + if (state.over || state.paused) return state; + const direction = state.pending[0] ?? state.direction; + const pending = state.pending.slice(1); + const head = state.snake[0]!; + const vector = VECTORS[direction]; + const next = { row: head.row + vector.row, col: head.col + vector.col }; + if ( + next.row < 0 || + next.row >= state.rows || + next.col < 0 || + next.col >= state.cols + ) { + return { ...state, direction, pending, over: true }; + } + const eating = state.food !== null && sameCell(next, state.food); + const body = eating ? state.snake : state.snake.slice(0, -1); + if (body.some((cell) => sameCell(cell, next))) { + return { ...state, direction, pending, over: true }; + } + const snake = eating + ? [next, ...state.snake] + : [next, ...state.snake.slice(0, -1)]; + const score = state.score + (eating ? 1 : 0); + if (!eating) { + return { ...state, snake, direction, pending, score }; + } + const food = spawnFood(state.rows, state.cols, snake, random); + return { + ...state, + snake, + direction, + pending, + food, + score, + over: food === null, + }; +} + +export function setWaitingSnakePaused( + state: WaitingSnakeState, + paused: boolean, +): WaitingSnakeState { + if (state.over || state.paused === paused) return state; + return { ...state, paused }; +} + +export function toggleWaitingSnakePause( + state: WaitingSnakeState, +): WaitingSnakeState { + return setWaitingSnakePaused(state, !state.paused); +} + +export function restartWaitingSnake( + state: WaitingSnakeState, + random: () => number = Math.random, +): WaitingSnakeState { + return createWaitingSnake({ + rows: state.rows, + cols: state.cols, + random, + }); +} + +/** 0 empty, 1 body, 2 head, 3 food. */ +export function waitingSnakeRenderGrid(state: WaitingSnakeState): Uint8Array { + const grid = new Uint8Array(state.rows * state.cols); + for (const cell of state.snake) { + if ( + cell.row < 0 || + cell.row >= state.rows || + cell.col < 0 || + cell.col >= state.cols + ) { + continue; + } + grid[cell.row * state.cols + cell.col] = 1; + } + const head = state.snake[0]; + if (head) grid[head.row * state.cols + head.col] = 2; + if (state.food) { + grid[state.food.row * state.cols + state.food.col] = 3; + } + return grid; +}