feat(web-shell): surface task Q&A activity above the composer (#16037)

* feat(web-shell): add a waiting minigame to the composer

Show a dismissible horizontal-Tetris strip above the main composer while a
prompt is streaming. The game closes when the turn ends, ignores editor
keyboard targets, and stays out of split panes and coarse-pointer devices.

Add engine and component tests, EN/ZH copy, design docs with page
screenshots, and a Chromium E2E scenario.

Refs #16036

* docs(web-shell): point the waiting minigame design at the committed E2E spec

* fix(web-shell): move the waiting minigame trigger into the status line

Replace the full-width waiting strip with a compact gamepad button at the
end of the streaming status row. The main composer now owns the open
state, and the board mounts above the status row only while open.

Keep the game's window keydown listener stable through a ref so rerenders
cannot drop the Escape that collapses the board, and update the component
tests, E2E scenario, screenshots, and both design docs.

* fix(web-shell): keep the waiting minigame scoped and recoverable

Pause and resume correctly after the tab is hidden, scope game keys to
the focused card so Escape no longer arms stream cancellation, let
focused buttons keep Space, restore the sound toggle, and keep the
board grid out of the aria-hidden wrapper that also held the overlays.

* fix(web-shell): keep the waiting minigame recoverable and scoped

Resume gravity after the tab becomes visible again, create the audio
context when sound is enabled, let the card's own buttons keep
Space/Enter, leave Escape to open dialogs, and keep the restart button
out of the aria-hidden board. The main composer now lets the open game
own Escape so the first press collapses it without arming stream cancel.

* feat(web-shell): add a waiting card with activity, usage and games

Open a waiting card from the streaming status button. The card starts on the
task's 44-bucket Q&A activity heatmap, switches to token usage instead of a
credits balance, and places a Qwen mark that opens a Tetris/Snake picker.
Games share the 44x7 grid and offer back-to-activity and switch-game actions.

Add the activity and Snake engines, component and E2E coverage, a local
harness with sample data, and design docs with page screenshots.

Refs #16036

* feat(web-shell): refine waiting card activity and game intros

Add hover details and click-to-locate to the activity heatmap, start a
random game from a delayed cold-cell Qwen entry, and play a QWEN CODE
intro that flies the blocks into the Tetris start cluster. Snake keeps
its intro static, eats an apple emoji, and starts at a slower speed.

* feat(web-shell): show task activity in a daily calendar

Replace the task-span buckets with a fixed 44-week day calendar, including
month labels, per-day hover details, click-to-locate and a stable card frame
across activity, usage, intro and game views. Gate the Qwen game entry behind
the waiting games configuration so the activity card stays the default
experience.

* fix(web-shell): show a gamepad icon on the configured game entry

Replace the Qwen brand mark in the calendar's game entry with a gamepad
icon, remove the now-unused Qwen logo component, and let the local harness
read the waiting games config so ?waitingGames=1 opens the preview with the
entry visible. Game screenshots and docs now describe the gamepad mark.

* fix(web-shell): read cached tokens from the connection fallback

The usage card falls back to the connection's token usage before any
assistant usage frame arrives, but the fallback read the message-level
cachedTokens field while the connection object carries cachedReadTokens.
Accept both names and pin the connection shape in the unit test.

* fix(web-shell): keep the game entry hit area inside its cell

Give the calendar game entry a positioning context so its forgiving hit
area stays bounded when prefers-reduced-motion disables the intro
animation and removes the transform that used to establish it. Cover the
reduced-motion path with an E2E that hovers and clicks a day while the
entry is visible.

Also reuse one Intl formatter pair per locale, format task token totals
above 1M as M, and drop the unused no-activity copy.

---------

Co-authored-by: yungsen.hys <[email protected]>
This commit is contained in:
samuelhsinandyungsen.hys authored and GitHub committed 2026-11-07 15:32:23 +08:00
1 parent f387c3a75a
commit 752e399b3b
35 files changed
+4907 -2

No files matched your search

@@ -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?
@@ -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` 保留最高分和音效偏好。
## 待确认
- 后续是否在欢迎页或触屏设备上增加入口和屏幕按键?
- 是否需要给宿主提供关闭开关?
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+64
View File
@@ -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<FailedPrompt | null>(null);
const failedPromptRef = useRef<FailedPrompt | null>(failedPrompt);
@@ -8888,6 +8916,9 @@ export function App({
);
const statusBarRef = useRef<StatusBarHandle>(null);
const messageListRef = useRef<MessageListHandle | null>(null);
const locateWaitingActivity = useCallback((messageId: string) => {
messageListRef.current?.scrollToMessage(messageId);
}, []);
const editorRef = useRef<EditorHandle | null>(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<DaemonStreamingState>(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({
</span>
</div>
)}
{waitingMinigameVisible && (
<WaitingMinigame
open={waitingMinigameOpen}
onClose={() => 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={
<WaitingMinigameButton
open={waitingMinigameOpen}
onToggle={() =>
setWaitingMinigameOpen((value) => !value)
}
/>
}
/>
)
) : newSessionSuggestion ? (
@@ -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: <button type="button">game</button> },
);
const status = container.firstElementChild;
expect(status?.lastElementChild?.textContent).toBe('game');
});
});
@@ -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')}
</span>
{trailing}
</div>
);
}
@@ -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;
}
}
@@ -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<WaitingMinigameProps> = {}): Rendered {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const rerender = (next: Partial<WaitingMinigameProps>) => {
act(() => {
root.render(
<I18nProvider language="en">
<WaitingMinigame
open={next.open ?? true}
onClose={next.onClose ?? vi.fn()}
activity={next.activity ?? activity}
tokenUsage={next.tokenUsage ?? tokens}
onSelectActivity={next.onSelectActivity}
gamesEnabled={next.gamesEnabled ?? true}
entryDelayMs={next.entryDelayMs ?? 0}
/>
</I18nProvider>,
);
});
};
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<HTMLButtonElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'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<HTMLButtonElement>('[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<typeof vi.fn>;
} {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const onToggle = vi.fn();
act(() => {
root.render(
<I18nProvider language="en">
<WaitingMinigameButton open={open} onToggle={onToggle} />
</I18nProvider>,
);
});
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<HTMLButtonElement>(
'[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');
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Waiting minigame harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/waiting-minigame-harness.tsx"></script>
</body>
</html>
@@ -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<WebShellLanguage>('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 (
<I18nProvider language={language}>
<div
style={{
minHeight: '100dvh',
background: dark ? '#0d0d0d' : '#ffffff',
color: dark ? '#fafafa' : '#0a0a0b',
fontFamily: 'Inter, sans-serif',
}}
>
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
borderBottom: `1px solid ${dark ? '#2a2a2a' : '#e5e7eb'}`,
fontSize: 12,
}}
>
<strong>Waiting minigame harness</strong>
<button type="button" onClick={toggleTheme}>
{dark ? 'Light theme' : 'Dark theme'}
</button>
<button
type="button"
onClick={() =>
setLanguage((value) => (value === 'en' ? 'zh-CN' : 'en'))
}
>
{language === 'en' ? '中文' : 'English'}
</button>
<button type="button" onClick={() => setSeed((value) => value + 3)}>
Random activity
</button>
<button
type="button"
onClick={() => setWithTokens((value) => !value)}
>
{withTokens ? 'Hide token data' : 'Show token data'}
</button>
<button
type="button"
onClick={() =>
setEntryDelayMs((value) => (value === 0 ? 5_000 : 0))
}
>
{entryDelayMs === 0 ? 'Game entry: instant' : 'Game entry: 5s'}
</button>
<button
type="button"
onClick={() => setGamesEnabled((value) => !value)}
>
Games: {gamesEnabled ? 'on' : 'off'}
</button>
<span>Next game:</span>
{(['random', 'tetris', 'snake'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={nextGame === kind}
onClick={() => setNextGame(kind)}
>
{kind}
</button>
))}
<button
type="button"
onClick={() => {
setOpen(false);
window.setTimeout(() => setOpen(true), 30);
}}
>
Reopen at activity
</button>
<span style={{ marginLeft: 'auto', opacity: 0.6 }}>
{located || 'Fake task · local preview'}
</span>
</header>
<main
data-web-shell-root
data-web-shell-shadcn
className={dark ? 'dark' : undefined}
style={{
minHeight: 'calc(100dvh - 43px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
gap: 10,
width: 'min(860px, calc(100% - 48px))',
margin: '0 auto',
padding: '24px 0 28px',
background: 'var(--background)',
color: 'var(--foreground)',
}}
>
<div style={{ flex: 1 }} />
{open && (
<WaitingMinigame
open={open}
onClose={() => setOpen(false)}
activity={activity}
tokenUsage={tokenUsage}
entryDelayMs={entryDelayMs}
gamesEnabled={gamesEnabled}
onSelectActivity={setLocated}
/>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minHeight: 24,
color: 'var(--muted-foreground)',
fontSize: 14,
}}
>
<span aria-hidden="true">⠋</span>
<span>Waiting for a reply</span>
<span style={{ fontSize: 13 }}>
(12s · ↓ 3 tokens · esc to cancel)
</span>
<span style={{ marginLeft: 'auto' }}>
<WaitingMinigameButton
open={open}
onToggle={() => setOpen((value) => !value)}
/>
</span>
</div>
<div
style={{
height: 132,
padding: '14px 16px',
border: '1px solid var(--border)',
borderRadius: 12,
background: 'var(--card)',
color: 'var(--muted-foreground)',
fontSize: 14,
}}
>
Processing. New messages will be queued.
</div>
</main>
</div>
</I18nProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Harness />
</React.StrictMode>,
);
@@ -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<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
async function gotoSession(
page: Page,
scenario: WebShellDaemonScenario,
daemon: MockDaemonController,
): Promise<void> {
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<void> {
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<void> {
await page.evaluate((forced) => {
Math.random = () => forced;
}, value);
}
async function waitForGame(game: ReturnType<Page['locator']>): Promise<void> {
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();
});
+43
View File
@@ -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');
+62
View File
@@ -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': '自动',
@@ -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 });
});
});
@@ -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<number, number>();
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) }
: {}),
};
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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));
}
});
});
@@ -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<WaitingMinigamePieceType, readonly Offset[]> = {
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<WaitingMinigamePieceType, readonly (readonly Offset[])[]>,
);
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;
}
@@ -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);
});
});
@@ -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<WaitingSnakeDirection, WaitingSnakeCell> = {
up: { row: -1, col: 0 },
down: { row: 1, col: 0 },
left: { row: 0, col: -1 },
right: { row: 0, col: 1 },
};
const OPPOSITE: Record<WaitingSnakeDirection, WaitingSnakeDirection> = {
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;
}