From 23d6e76abb467a17f86749a3730112895e9b7083 Mon Sep 17 00:00:00 2001
From: yuanyuanAli <15395845800@163.com>
Date: Thu, 9 Jul 2026 16:56:11 +0800
Subject: [PATCH 01/11] feat(web-shell): add mobile welcome composer slots
---
packages/web-shell/client/App.module.css | 78 +++++
packages/web-shell/client/App.tsx | 270 ++++++++++++------
.../client/components/MessageList.module.css | 8 +
.../client/components/MessageList.tsx | 9 +-
packages/web-shell/client/customization.tsx | 4 +
packages/web-shell/client/index.ts | 1 +
packages/web-shell/client/index.tsx | 1 +
7 files changed, 288 insertions(+), 83 deletions(-)
diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css
index d4e713bc94f..a5d9ac62a34 100644
--- a/packages/web-shell/client/App.module.css
+++ b/packages/web-shell/client/App.module.css
@@ -675,6 +675,14 @@
padding: 0;
}
+.composerHeader {
+ margin-bottom: 8px;
+}
+
+.customFooter {
+ flex-shrink: 0;
+}
+
/* Esc-clear hint, shown in the composer's top status slot (where the streaming
loader sits) — the two never coexist, so it stays clear of the queue. */
.escClearStatus {
@@ -688,6 +696,76 @@
padding: 12px 0 0;
}
+.mobileWelcomeFooterMiddle {
+ display: none;
+}
+
+.mobileWelcomeGroup {
+ display: contents;
+}
+
+@media (max-width: 760px) {
+ .appChatEmpty .chatPaneWithMobileComposerBottom {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .appChatEmpty .chatViewWithMobileComposerBottom .footer {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin-top: 0;
+ }
+
+ .appChatEmpty .mobileWelcomeGroup {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .appChatEmpty .chatViewWithWelcomeMiddle .footerWithCustomFooter {
+ display: contents;
+ }
+
+ .appChatEmpty .chatViewWithWelcomeMiddle .footerWithCustomFooter .composer {
+ order: 2;
+ width: min(100%, var(--chat-shell-width));
+ margin: 0 auto;
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+
+ .appChatEmpty .chatViewWithWelcomeMiddle .mobileWelcomeFooterMiddle {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: flex-start;
+ justify-content: center;
+ width: min(100%, var(--chat-shell-width));
+ margin: 0 auto;
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+
+ .appChatEmpty .chatViewWithWelcomeMiddle .customFooter {
+ order: 1;
+ display: flex;
+ flex: 0 0 auto;
+ align-items: flex-start;
+ justify-content: center;
+ width: min(100%, var(--chat-shell-width));
+ margin: 0 auto;
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+ .desktopWelcomeFooter {
+ display: none;
+ }
+
+ .emptyWelcomeFooter {
+ text-align: center;
+ }
+}
+
.queuedPrompts {
display: flex;
flex-direction: column;
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 74652947cd2..54994f059aa 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -179,6 +179,7 @@ import {
type ComposerToolbarStartRenderer,
type ComposerToolbarEndRenderer,
type ComposerToolbarRightRenderer,
+ type ComposerHeaderRenderer,
type FooterRenderer,
type LoadingPhrasesResolver,
type MarkdownTableMode,
@@ -423,6 +424,8 @@ export interface WebShellProps {
renderWelcomeHeader?: WelcomeHeaderRenderer;
/** Custom renderer shown below the chat composer in the empty welcome state. */
renderWelcomeFooter?: WelcomeFooterRenderer;
+ /** Show renderWelcomeFooter between the welcome header and composer on mobile empty state. */
+ mobileWelcomeFooterMiddle?: boolean;
/** Custom renderer for the inside of user chat bubbles. Defaults to plain text. */
renderUserMessageContent?: UserMessageContentRenderer;
/** Custom renderer inserted before the built-in chat composer toolbar controls. */
@@ -431,6 +434,8 @@ export interface WebShellProps {
renderComposerToolbarEnd?: ComposerToolbarEndRenderer;
/** Custom renderer inserted into the composer toolbar's right-side action area. */
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
+ /** Custom renderer shown directly above the chat composer input. */
+ renderComposerHeader?: ComposerHeaderRenderer;
/** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */
renderFooter?: FooterRenderer;
/** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */
@@ -835,10 +840,12 @@ export function App({
renderToolHeaderExtra,
renderWelcomeHeader,
renderWelcomeFooter,
+ mobileWelcomeFooterMiddle = false,
renderUserMessageContent,
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
+ renderComposerHeader,
renderFooter,
chatMaxWidth,
sidebar,
@@ -954,6 +961,7 @@ export function App({
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
+ renderComposerHeader,
renderFooter,
compactThinking,
collapseCompletedTurns,
@@ -969,6 +977,7 @@ export function App({
renderComposerToolbarStart,
renderComposerToolbarEnd,
renderComposerToolbarRight,
+ renderComposerHeader,
renderFooter,
compactThinking,
collapseCompletedTurns,
@@ -978,6 +987,7 @@ export function App({
],
);
const CustomFooter = renderFooter;
+ const CustomComposerHeader = renderComposerHeader;
const store = useTranscriptStore();
const blocks = useTranscriptBlocks();
const connection = useConnection();
@@ -4275,6 +4285,13 @@ export function App({
!showFloatingTodos &&
!pendingApproval &&
!btwMessage;
+ const useMobileWelcomeMiddleLayout =
+ isChatEmptyState && mobileWelcomeFooterMiddle;
+ const showMobileWelcomeFooterMiddle =
+ useMobileWelcomeMiddleLayout && Boolean(welcomeFooter);
+ const hasWelcomeMiddle = isChatEmptyState && showMobileWelcomeFooterMiddle;
+ const hasMobileComposerBottom =
+ isChatEmptyState && useMobileWelcomeMiddleLayout;
const missingSession =
connection.status !== 'connecting' &&
!connection.sessionId &&
@@ -4656,11 +4673,16 @@ export function App({
)}
{sidebarOptions.enabled &&
!activePanel &&
@@ -4881,11 +4903,20 @@ export function App({
)}
-
0 ||
- pendingApproval
- ? styles.contentHasMessages
- : undefined,
- ]
- .filter(Boolean)
- .join(' ')}
- >
-
- {btwMessage?.role === 'btw' && (
-
-
+ 0 ||
+ pendingApproval
+ ? styles.contentHasMessages
+ : undefined,
+ ]
+ .filter(Boolean)
+ .join(' ')}
+ >
+
- )}
-
+
+ {welcomeFooter}
+
+
+ ) : (
+
0 ||
+ pendingApproval
+ ? styles.contentHasMessages
+ : undefined,
+ ]
+ .filter(Boolean)
+ .join(' ')}
+ >
+
+ {btwMessage?.role === 'btw' && (
+
+
+
+ )}
+
+ )}
-
+
{canScrollMessageListToBottom && (
+ {CustomComposerHeader && (
+
+
+
+ )}
{CustomFooter ? (
-
0
- ? (connection.tokenCount ?? 0) /
- (connection.contextWindow ?? 0)
- : 0
- }
- activeGoal={activeGoal}
- tasks={footerTasks}
- availableModes={MODES_CYCLE}
- availableModels={(connection.models ?? [])
- .filter(isVisibleComposerModel)
- .map((m) => ({
- id: m.id,
- label: getModelDisplayName(m.label || m.id),
- contextWindow: m.contextWindow,
- }))}
- skills={loadedSkills}
- onSelectMode={handleSetMode}
- onSelectModel={handleModelSelect}
- />
+
+ 0
+ ? (connection.tokenCount ?? 0) /
+ (connection.contextWindow ?? 0)
+ : 0
+ }
+ activeGoal={activeGoal}
+ tasks={footerTasks}
+ availableModes={MODES_CYCLE}
+ availableModels={(connection.models ?? [])
+ .filter(isVisibleComposerModel)
+ .map((m) => ({
+ id: m.id,
+ label: getModelDisplayName(m.label || m.id),
+ contextWindow: m.contextWindow,
+ }))}
+ skills={loadedSkills}
+ onSelectMode={handleSetMode}
+ onSelectModel={handleModelSelect}
+ />
+
) : (
@@ -5153,7 +5250,16 @@ export function App({
/>
)}
{isChatEmptyState && welcomeFooter && (
-
+
{welcomeFooter}
)}
diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css
index 9d535d4dbba..22cac24ed22 100644
--- a/packages/web-shell/client/components/MessageList.module.css
+++ b/packages/web-shell/client/components/MessageList.module.css
@@ -7,6 +7,14 @@
min-height: 0;
}
+@media (max-width: 760px) {
+ .listWithWelcomeHeader {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+}
+
.list::-webkit-scrollbar {
width: 6px;
}
diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx
index 4250cd38d8f..99720d42e8e 100644
--- a/packages/web-shell/client/components/MessageList.tsx
+++ b/packages/web-shell/client/components/MessageList.tsx
@@ -45,6 +45,7 @@ interface MessageListProps {
*/
isResponding?: boolean;
welcomeHeader?: ReactNode;
+ centerWelcomeHeader?: boolean;
workspaceCwd?: string;
tailContent?: ReactNode;
tailKey?: string;
@@ -1821,6 +1822,7 @@ export const MessageList = memo(
isResponding = false,
activeTurnStartedAt,
welcomeHeader,
+ centerWelcomeHeader = false,
workspaceCwd,
tailContent,
tailKey = 'tail',
@@ -2851,7 +2853,12 @@ export const MessageList = memo(
return (
diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx
index 2f1858be433..6a9c037d1fc 100644
--- a/packages/web-shell/client/customization.tsx
+++ b/packages/web-shell/client/customization.tsx
@@ -193,6 +193,9 @@ export type ComposerToolbarEndRenderer =
export type ComposerToolbarRightRenderer =
ComponentType
;
+export type ComposerHeaderRenderer =
+ ComponentType;
+
// ---- Background task info (public type for footer renderer) ----
interface WebShellTaskBase {
@@ -290,6 +293,7 @@ export interface WebShellCustomization {
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
renderComposerToolbarEnd?: ComposerToolbarEndRenderer;
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
+ renderComposerHeader?: ComposerHeaderRenderer;
renderFooter?: FooterRenderer;
compactThinking?: boolean;
/**
diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts
index a3c2c4483ee..07e104c87fe 100644
--- a/packages/web-shell/client/index.ts
+++ b/packages/web-shell/client/index.ts
@@ -20,6 +20,7 @@ export type {
UserMessageContentRenderer,
UserMessageContentRenderInfo,
ComposerToolbarStartRenderer,
+ ComposerHeaderRenderer,
ComposerToolbarRightRenderer,
WelcomeFooterRenderer,
WebShellComposerApi,
diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx
index 1264796f558..e1fb312d4b7 100644
--- a/packages/web-shell/client/index.tsx
+++ b/packages/web-shell/client/index.tsx
@@ -117,6 +117,7 @@ export type {
ToolHeaderKind,
UserMessageContentRenderer,
UserMessageContentRenderInfo,
+ ComposerHeaderRenderer,
ComposerToolbarStartRenderer,
ComposerToolbarRightRenderer,
WebShellComposerToolbarRenderInfo,
From 6718aae3f9310365c984f2d7d60e019a2c8d829f Mon Sep 17 00:00:00 2001
From: yuanyuanAli <15395845800@163.com>
Date: Thu, 9 Jul 2026 19:34:07 +0800
Subject: [PATCH 02/11] refactor(web-shell): deduplicate MessageList JSX and
remove dead CSS reference
- Extract ~80 lines of duplicated MessageList rendering into shared variables with conditional props and wrapper
- Remove dead chatPaneWithWelcomeMiddle className reference (CSS class never defined)
- Document mobileWelcomeFooterMiddle dependency on renderWelcomeFooter in JSDoc
---
packages/web-shell/client/App.tsx | 112 +++++++++++++-----------------
1 file changed, 49 insertions(+), 63 deletions(-)
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 54994f059aa..3997c7e1b8b 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -424,7 +424,11 @@ export interface WebShellProps {
renderWelcomeHeader?: WelcomeHeaderRenderer;
/** Custom renderer shown below the chat composer in the empty welcome state. */
renderWelcomeFooter?: WelcomeFooterRenderer;
- /** Show renderWelcomeFooter between the welcome header and composer on mobile empty state. */
+ /**
+ * Show renderWelcomeFooter between the welcome header and composer on
+ * mobile empty state. Requires renderWelcomeFooter to be provided for the
+ * mobile CSS reordering to take effect.
+ */
mobileWelcomeFooterMiddle?: boolean;
/** Custom renderer for the inside of user chat bubbles. Defaults to plain text. */
renderUserMessageContent?: UserMessageContentRenderer;
@@ -4679,7 +4683,6 @@ export function App({
hasMobileComposerBottom
? styles.chatPaneWithMobileComposerBottom
: undefined,
- hasWelcomeMiddle ? styles.chatPaneWithWelcomeMiddle : undefined,
]
.filter(Boolean)
.join(' ')}
@@ -4954,63 +4957,19 @@ export function App({
timeline={todoTimeline}
details={todoDetails}
>
- {showMobileWelcomeFooterMiddle ? (
-
-
0 ||
- pendingApproval
- ? styles.contentHasMessages
- : undefined,
- ]
- .filter(Boolean)
- .join(' ')}
- >
-
-
-
- {welcomeFooter}
-
-
- ) : (
- 0 ||
- pendingApproval
- ? styles.contentHasMessages
- : undefined,
- ]
- .filter(Boolean)
- .join(' ')}
- >
+ {(() => {
+ const contentClassName = [
+ styles.content,
+ showFloatingTodos ||
+ displayMessages.length > 0 ||
+ pendingApproval
+ ? styles.contentHasMessages
+ : undefined,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ const messageList = (
- {btwMessage?.role === 'btw' && (
+ );
+
+ const btwPanel =
+ !showMobileWelcomeFooterMiddle &&
+ btwMessage?.role === 'btw' ? (
- )}
-
- )}
+ ) : null;
+
+ const contentArea = (
+
+ {messageList}
+ {btwPanel}
+
+ );
+
+ if (showMobileWelcomeFooterMiddle) {
+ return (
+
+ {contentArea}
+
+ {welcomeFooter}
+
+
+ );
+ }
+ return contentArea;
+ })()}
From 5e63fea20da42f01d5d32e0061f49ff8f41fba38 Mon Sep 17 00:00:00 2001
From: yuanyuanAli <15395845800@163.com>
Date: Fri, 10 Jul 2026 10:02:30 +0800
Subject: [PATCH 03/11] fix(web-shell): stabilize MessageList tree position and
conditional customFooter wrapper
- Use stable outer wrapper div for IIFE to prevent MessageList unmount/remount when showMobileWelcomeFooterMiddle toggles
- Only wrap CustomFooter in styles.customFooter div when hasMobileComposerBottom is true, avoiding DOM depth change for non-mobile consumers
---
packages/web-shell/client/App.tsx | 57 +++++++++++++++++++++++++------
1 file changed, 46 insertions(+), 11 deletions(-)
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index cb431b8645f..2afc0542174 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -5082,17 +5082,16 @@ export function App({
) : null;
- const contentArea = (
-
- {messageList}
- {btwPanel}
-
- );
-
if (showMobileWelcomeFooterMiddle) {
return (
- {contentArea}
+
+ {messageList}
+ {btwPanel}
+
@@ -5101,7 +5100,15 @@ export function App({
);
}
- return contentArea;
+ return (
+
+ {messageList}
+ {btwPanel}
+
+ );
})()}
@@ -5262,7 +5269,35 @@ export function App({
/>
{CustomFooter ? (
-
+ hasMobileComposerBottom ? (
+
+ 0
+ ? (connection.tokenCount ?? 0) /
+ (connection.contextWindow ?? 0)
+ : 0
+ }
+ activeGoal={activeGoal}
+ tasks={footerTasks}
+ availableModes={MODES_CYCLE}
+ availableModels={(connection.models ?? [])
+ .filter(isVisibleComposerModel)
+ .map((m) => ({
+ id: m.id,
+ label: getModelDisplayName(m.label || m.id),
+ contextWindow: m.contextWindow,
+ }))}
+ skills={loadedSkills}
+ onSelectMode={handleSetMode}
+ onSelectModel={handleModelSelect}
+ />
+
+ ) : (
-
+ )
) : (
From c1f03fa9ddab90f7c8a03e2ba1a148da092783bb Mon Sep 17 00:00:00 2001
From: qqqys
Date: Sat, 11 Jul 2026 02:24:39 +0800
Subject: [PATCH 04/11] fix(release): raise package size budget to 85 MiB
(#6688)
---
scripts/prepare-package.js | 2 +-
scripts/tests/package-assets.test.js | 28 ++++++++++++++++++++++++++++
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js
index d9cb9c9aaf2..cd96729cb13 100644
--- a/scripts/prepare-package.js
+++ b/scripts/prepare-package.js
@@ -19,7 +19,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const defaultRootDir = path.resolve(__dirname, '..');
const TEST_FILE_RE = /\.(test|spec)\.(d\.)?[mc]?[jt]s(\.map)?$/;
-const DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES = 80 * 1024 * 1024;
+const DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES = 85 * 1024 * 1024;
const PACKAGE_TEXT_FILE_RE =
/\.(?:[cm]?[jt]sx?|json|md|html|css|txt|ya?ml|sh|svg|map)$/i;
const PACKAGE_SCAN_FORBIDDEN_LITERALS = [
diff --git a/scripts/tests/package-assets.test.js b/scripts/tests/package-assets.test.js
index fff5e7d6477..08cb51f5ab3 100644
--- a/scripts/tests/package-assets.test.js
+++ b/scripts/tests/package-assets.test.js
@@ -11,6 +11,7 @@ import {
readFileSync,
readdirSync,
rmSync,
+ truncateSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
@@ -324,6 +325,33 @@ describe('package asset scripts', () => {
).toThrow(/Prepared package unpacked size \d+ bytes exceeds 50000 bytes/);
});
+ it('enforces an 85 MiB default unpacked size budget', () => {
+ const rootDir = createFixtureRoot();
+ createBundleArtifacts(rootDir);
+ writeFile(rootDir, 'dist/chunks/large.bin', '');
+ const largeFile = path.join(rootDir, 'dist', 'chunks', 'large.bin');
+ truncateSync(largeFile, 84 * 1024 * 1024);
+ stubConsole();
+
+ expect(() =>
+ preparePackage({
+ rootDir,
+ requireNativeAudioCapture: false,
+ }),
+ ).not.toThrow();
+
+ truncateSync(largeFile, 85 * 1024 * 1024);
+
+ expect(() =>
+ preparePackage({
+ rootDir,
+ requireNativeAudioCapture: false,
+ }),
+ ).toThrow(
+ /Prepared package unpacked size \d+ bytes exceeds 89128960 bytes/,
+ );
+ });
+
it('omits bundledDependencies when audio-capture artifacts are missing', () => {
const rootDir = createFixtureRoot();
rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), {
From aa52da82764307aa59276756d6c265dccd0f5eec Mon Sep 17 00:00:00 2001
From: qwen-code-dev-bot
Date: Sat, 11 Jul 2026 03:06:55 +0800
Subject: [PATCH 05/11] fix(interactive): configure Docker sandbox networking
for protocol tag retry test (#6684) (#6689)
The protocol-tags-interactive.test.ts started the fake OpenAI server
on 127.0.0.1 without Docker-aware host options, making it unreachable
from inside the Docker sandbox container. The CLI running in the
container tried to connect to 127.0.0.1 which resolved to the
container's own loopback, not the host where the test server listens.
Bind the fake server to 0.0.0.0 and advertise host.docker.internal
as the base URL host when QWEN_SANDBOX is docker or podman, matching
the established pattern in tool-control.test.ts. Also set NO_PROXY to
include host.docker.internal so the CLI does not route sandbox model
requests through an HTTP proxy.
Co-authored-by: qwen-autofix[bot]
---
.../protocol-tags-interactive.test.ts | 85 +++++++++++++------
1 file changed, 59 insertions(+), 26 deletions(-)
diff --git a/integration-tests/interactive/protocol-tags-interactive.test.ts b/integration-tests/interactive/protocol-tags-interactive.test.ts
index 73c2ebc4be2..a45a7a238ad 100644
--- a/integration-tests/interactive/protocol-tags-interactive.test.ts
+++ b/integration-tests/interactive/protocol-tags-interactive.test.ts
@@ -12,52 +12,85 @@ import {
} from '../fake-openai-server.js';
import { TestRig, type } from '../test-helper.js';
+const SANDBOX_MODE = process.env['QWEN_SANDBOX']?.toLowerCase().trim();
+const IS_CONTAINER_SANDBOX =
+ SANDBOX_MODE === 'docker' || SANDBOX_MODE === 'podman';
+
describe('Interactive protocol tag retry guard', () => {
let fakeServer: FakeOpenAIServer | undefined;
let rig: TestRig;
+ let savedNoProxy: string | undefined;
+ let savedNoProxyLower: string | undefined;
beforeEach(() => {
rig = new TestRig();
+ if (IS_CONTAINER_SANDBOX) {
+ savedNoProxy = process.env['NO_PROXY'];
+ savedNoProxyLower = process.env['no_proxy'];
+ const noProxy = '127.0.0.1,localhost,host.docker.internal';
+ process.env['NO_PROXY'] = noProxy;
+ process.env['no_proxy'] = noProxy;
+ }
});
afterEach(async () => {
await fakeServer?.close();
fakeServer = undefined;
+ if (IS_CONTAINER_SANDBOX) {
+ if (savedNoProxy !== undefined) {
+ process.env['NO_PROXY'] = savedNoProxy;
+ } else {
+ delete process.env['NO_PROXY'];
+ }
+ if (savedNoProxyLower !== undefined) {
+ process.env['no_proxy'] = savedNoProxyLower;
+ } else {
+ delete process.env['no_proxy'];
+ }
+ }
await rig.cleanup();
});
it.skipIf(process.platform === 'win32')(
'retries protocol leaks across SSE disconnect and completed streams',
async () => {
- fakeServer = await startFakeOpenAIServer(({ requestIndex }) => {
- if (requestIndex === 0) {
- return {
- contentChunks: [
- 'hidden before disconnect',
- 'WRONG_FIRST_ATTEMPT',
- ],
- disconnectAfterContentChunks: 1,
- };
- }
+ fakeServer = await startFakeOpenAIServer(
+ ({ requestIndex }) => {
+ if (requestIndex === 0) {
+ return {
+ contentChunks: [
+ 'hidden before disconnect',
+ 'WRONG_FIRST_ATTEMPT',
+ ],
+ disconnectAfterContentChunks: 1,
+ };
+ }
+
+ if (requestIndex === 1) {
+ return {
+ contentChunks: [
+ 'hidden completed attempt',
+ 'WRONG_COMPLETED_SUMMARY',
+ ],
+ };
+ }
- if (requestIndex === 1) {
return {
- contentChunks: [
- 'hidden completed attempt',
- 'WRONG_COMPLETED_SUMMARY',
- ],
+ contentChunks: ['VISIBLE_TMUX_RETRY_RESPONSE_DONE'],
+ usage: {
+ prompt_tokens: 20,
+ completion_tokens: 8,
+ total_tokens: 28,
+ },
};
- }
-
- return {
- contentChunks: ['VISIBLE_TMUX_RETRY_RESPONSE_DONE'],
- usage: {
- prompt_tokens: 20,
- completion_tokens: 8,
- total_tokens: 28,
- },
- };
- });
+ },
+ IS_CONTAINER_SANDBOX
+ ? {
+ listenHost: '0.0.0.0',
+ baseUrlHost: 'host.docker.internal',
+ }
+ : undefined,
+ );
await rig.setup('interactive-protocol-tag-filtering-http-retry', {
settings: {
From 3b823aec00feddef9ff8b3707baf1ce26aae22f6 Mon Sep 17 00:00:00 2001
From: nas <156536069+Nas01010101@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:50:57 -0400
Subject: [PATCH 06/11] fix(core): keep YOLO mode when the model calls
enter_plan_mode (#6630)
* fix(core): keep YOLO mode when the model calls enter_plan_mode
A model-initiated enter_plan_mode call from YOLO silently switched the
session into the read-only Plan mode, surprising users who explicitly
chose YOLO for low-friction execution and then blocking the reads/writes
they expected to proceed. Genuine user-driven plan-mode entries
(Shift+Tab, /plan) call setApprovalMode directly and never route through
this tool, so guarding the tool only affects the model deciding to plan
on its own. From YOLO the tool now keeps the current mode and returns a
message telling the model to continue planning without switching.
Fixes #5970
* fix(core): gate the YOLO plan-mode guard on an explicit user request
Addresses review feedback on #6630.
The previous guard suppressed every enter_plan_mode invocation while the
session was in YOLO mode. That fixes the unsolicited switch reported in
#5970, but it also blocks the legitimate path: the tool description tells
the model to call this tool only after the user explicitly asks, and
/plan is interactive-only (supportedModes: ['interactive']) with no
Shift+Tab equivalent. In a headless or ACP YOLO session the tool is the
only door into plan mode, so a blanket guard made an explicit user
request unreachable.
Add an optional userRequested flag to the tool schema and only no-op when
the entry is NOT user-requested. A user-requested entry still goes through
setApprovalMode(PLAN, { enteredByModel: true }) so the Plan Approval Gate
on exit continues to run for AUTO/YOLO sessions (#5574).
* fix(core): address review suggestions on the YOLO plan-mode guard
- Log via debugLogger.info when the guard suppresses a model-initiated
entry, so a "I asked for plan mode and nothing happened" report is
diagnosable by grepping ENTER_PLAN_MODE (the other early-return paths
already log).
- Strengthen the userRequested:false test to assert on the returned
llmContent/returnDisplay, matching the unsolicited-entry sibling test.
- Add a defensive test pinning that userRequested is inert outside
YOLO: DEFAULT with the flag set enters plan mode normally.
---------
Co-authored-by: Shaojin Wen
---
packages/core/src/tools/enterPlanMode.test.ts | 88 ++++++++++++++++++-
packages/core/src/tools/enterPlanMode.ts | 45 +++++++++-
2 files changed, 127 insertions(+), 6 deletions(-)
diff --git a/packages/core/src/tools/enterPlanMode.test.ts b/packages/core/src/tools/enterPlanMode.test.ts
index 8f918d4927f..3fb3f1c2354 100644
--- a/packages/core/src/tools/enterPlanMode.test.ts
+++ b/packages/core/src/tools/enterPlanMode.test.ts
@@ -67,10 +67,15 @@ describe('EnterPlanModeTool', () => {
expect(tool.shouldDefer).toBe(false);
});
- it('should have empty-object schema', () => {
+ it('should expose only the userRequested flag in its schema', () => {
expect(tool.schema.parametersJsonSchema).toEqual({
type: 'object',
- properties: {},
+ properties: {
+ userRequested: {
+ type: 'boolean',
+ description: expect.stringContaining('ONLY when the user'),
+ },
+ },
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
});
@@ -124,16 +129,91 @@ describe('EnterPlanModeTool', () => {
expect(savedPrePlanMode).toBe(ApprovalMode.AUTO);
});
- it('should switch from YOLO to PLAN', async () => {
+ it('should not switch from YOLO to PLAN when the entry is unsolicited', async () => {
+ // Regression: #5970. A YOLO user opted into low-friction execution;
+ // silently switching to read-only Plan mode surprised them and then
+ // blocked reads/writes they expected to proceed. A model-initiated
+ // enter_plan_mode from YOLO must keep the current mode instead.
approvalMode = ApprovalMode.YOLO;
const invocation = tool.build({});
- await invocation.execute(new AbortController().signal);
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
+ expect(approvalMode).toBe(ApprovalMode.YOLO);
+ expect(savedPrePlanMode).toBeUndefined();
+ expect(result.llmContent).toContain('YOLO');
+ expect(result.llmContent).not.toContain('Plan mode is now active');
+ // The model must be told how to honour an explicit user request.
+ expect(result.llmContent).toContain('userRequested: true');
+ });
+
+ it('should not switch from YOLO to PLAN when userRequested is explicitly false', async () => {
+ approvalMode = ApprovalMode.YOLO;
+ const invocation = tool.build({ userRequested: false });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
+ expect(approvalMode).toBe(ApprovalMode.YOLO);
+ expect(result.llmContent).toContain('YOLO');
+ expect(result.llmContent).not.toContain('Plan mode is now active');
+ expect(result.returnDisplay).toContain('Stayed in YOLO');
+ });
+
+ it('should treat userRequested as inert outside YOLO (DEFAULT enters PLAN normally)', async () => {
+ // Defensive: the flag only gates the YOLO no-op. If it ever gained
+ // significance in other modes, this pins the expected behavior.
+ approvalMode = ApprovalMode.DEFAULT;
+ const invocation = tool.build({ userRequested: true });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
+ ApprovalMode.PLAN,
+ { enteredByModel: true },
+ );
+ expect(approvalMode).toBe(ApprovalMode.PLAN);
+ expect(savedPrePlanMode).toBe(ApprovalMode.DEFAULT);
+ expect(result.llmContent).toContain('Plan mode is now active');
+ });
+
+ it('should switch from YOLO to PLAN when the user explicitly requested it', async () => {
+ // The tool description instructs the model to call this only after the
+ // user asks, and `/plan` is interactive-only — so this tool is the only
+ // door into plan mode for headless/ACP sessions. A blanket YOLO guard
+ // would make an explicit user request unreachable there.
+ approvalMode = ApprovalMode.YOLO;
+ const invocation = tool.build({ userRequested: true });
+ const result = await invocation.execute(new AbortController().signal);
+ // Still flagged as model-initiated so exit_plan_mode runs the Plan
+ // Approval Gate for the YOLO session (#5574).
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
{ enteredByModel: true },
);
+ expect(approvalMode).toBe(ApprovalMode.PLAN);
expect(savedPrePlanMode).toBe(ApprovalMode.YOLO);
+ expect(result.llmContent).toContain('Plan mode is now active');
+ });
+
+ it('should honour a user-requested YOLO entry in an ACP session', async () => {
+ // Headless + ACP: no `/plan`, no Shift+Tab. This tool is the only path.
+ approvalMode = ApprovalMode.YOLO;
+ (mockConfig.isInteractive as ReturnType).mockReturnValue(
+ false,
+ );
+ (
+ mockConfig.getExperimentalZedIntegration as ReturnType
+ ).mockReturnValue(true);
+
+ const invocation = tool.build({ userRequested: true });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
+ ApprovalMode.PLAN,
+ { enteredByModel: true },
+ );
+ expect(approvalMode).toBe(ApprovalMode.PLAN);
+ expect(result.llmContent).not.toContain('non-interactive');
});
it('should be idempotent: already in PLAN does not call setApprovalMode', async () => {
diff --git a/packages/core/src/tools/enterPlanMode.ts b/packages/core/src/tools/enterPlanMode.ts
index e48507dd912..41a6b5c7e50 100644
--- a/packages/core/src/tools/enterPlanMode.ts
+++ b/packages/core/src/tools/enterPlanMode.ts
@@ -20,7 +20,15 @@ import {
const debugLogger = createDebugLogger('ENTER_PLAN_MODE');
-export type EnterPlanModeParams = Record;
+export interface EnterPlanModeParams {
+ /**
+ * Set to `true` only when the user explicitly asked for plan mode in this
+ * turn (or explicitly confirmed they want it). Distinguishes a genuine
+ * user-requested entry from the model deciding to plan on its own, which
+ * matters when the session is in YOLO mode — see the guard in `execute()`.
+ */
+ userRequested?: boolean;
+}
const enterPlanModeToolDescription = `Use this tool only after the user explicitly asks to switch into plan mode or confirms they want plan mode. Entering plan mode is a privilege reduction, so it does not require user confirmation at execution time.
@@ -38,7 +46,13 @@ const enterPlanModeToolSchemaData: FunctionDeclaration = {
description: enterPlanModeToolDescription,
parametersJsonSchema: {
type: 'object',
- properties: {},
+ properties: {
+ userRequested: {
+ type: 'boolean',
+ description:
+ 'Set to true ONLY when the user explicitly asked for plan mode in this turn, or explicitly confirmed they want it. Leave unset (or false) when you are deciding to plan on your own without the user asking. In YOLO mode, an explicit user request will not take effect unless this is true.',
+ },
+ },
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
@@ -76,6 +90,33 @@ class EnterPlanModeToolInvocation extends BaseToolInvocation<
);
}
+ // A model-initiated entry from YOLO (not requested by the user this
+ // turn) is a no-op. The user explicitly chose YOLO for low-friction
+ // execution; silently switching to the read-only Plan mode surprises
+ // them and then blocks the reads/writes they expected to proceed
+ // (#5970). This tool is ALSO the only door into plan mode in
+ // headless/ACP sessions — `/plan` is `interactive`-only and there is no
+ // Shift+Tab there — so a blanket YOLO guard would make a genuine,
+ // explicit user request unreachable in those sessions. `userRequested`
+ // lets the model tell the two apart: only gate the no-op when the
+ // entry is NOT user-requested. Keep the current mode and tell the
+ // model to continue planning without switching, or to retry with
+ // `userRequested: true` if the user did explicitly ask.
+ if (
+ this.config.getApprovalMode() === ApprovalMode.YOLO &&
+ !this.params.userRequested
+ ) {
+ debugLogger.info(
+ 'Blocked model-initiated plan entry from YOLO (userRequested=%s)',
+ this.params.userRequested,
+ );
+ return {
+ llmContent:
+ 'Plan mode was not entered: the session is in YOLO mode, which the user explicitly chose for low-friction execution. Continue investigating and presenting your plan in the current mode without switching. If the user explicitly asked for plan mode in this turn, retry this tool call with userRequested: true.',
+ returnDisplay: 'Stayed in YOLO mode (plan mode not entered).',
+ };
+ }
+
// In headless (non-interactive) mode without ACP support, the gate
// exit paths require user interaction that cannot be fulfilled.
const isAcpMode =
From e21a816ebc39619d501a701db8c91ecd8bbcdb61 Mon Sep 17 00:00:00 2001
From: Tianyuan <2720711917@qq.com>
Date: Sat, 11 Jul 2026 07:53:46 +0800
Subject: [PATCH 07/11] feat(cli): forward ask_user_question answers from SDK
can_use_tool (#6655)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(cli): forward ask_user_question answers from SDK can_use_tool
SDK-hosted agents could receive ask_user_question calls through the
can_use_tool callback and approve them, but the user's answers never
reached the tool: the CLI called onConfirm(ProceedOnce) with no payload,
so the tool read an empty answers map and the model never got the
decisions.
Route updatedInput.answers from the SDK's allow response into the tool
confirmation payload so the collected answers reach the tool. Reuses the
existing updatedInput channel — no new SDK API or types. Document the
pattern in the TypeScript and Python SDK READMEs.
* fix(cli): forward ask_user_question answers on teammate approval path
Address review feedback on #6655:
- handleTeammateApproval now mirrors the leader path and promotes the
user's answers from updatedInput into the confirmation payload, so
ask_user_question calls approved through a teammate no longer drop the
user's choices (wenshao).
- Extract a shared buildAllowConfirmationPayload helper used by both the
leader and teammate paths, and only promote `answers` for
ask_user_question so a same-named field on any other tool's input can't
leak into the payload.
- Add tests for the teammate path and the defensive guards (array
updatedInput, array/null/empty answers, foreign answers field).
* test(web-shell): stub Range client-rect methods to fix flaky CI
CodeMirror's async measure pass (scheduled via requestAnimationFrame)
calls getClientRects()/getBoundingClientRect() on a text Range. jsdom
implements these on Element but not on Range, so the call throws
"textRange(...).getClientRects is not a function" from a rAF callback
after the test completed. Vitest surfaces it as an unhandled error and
fails the whole run with exit code 1 even though every assertion passed
(seen intermittently in useComposerCore.dom.test.tsx).
Polyfill both methods on Range.prototype in the shared test setup,
mirroring the existing ResizeObserver/scrollIntoView stubs.
* refactor(cli): use ToolNames constant and broaden permission tests
Address review suggestions on #6655:
- buildAllowConfirmationPayload now gates answers-promotion on the
ToolNames.ASK_USER_QUESTION constant instead of a bare string literal,
so a future rename of the tool name is a compile-time break rather than
a silent regression.
- Add an it.each case for a non-object primitive updatedInput (string) to
cover the `typeof updatedInput !== 'object'` guard branch.
- Assert the leader path overrides toolCall.request.args with the host's
sanitized updatedInput before confirming.
- Add a teammate-path test for an allow response with no updatedInput,
asserting respond is called with (ProceedOnce, undefined).
---------
Co-authored-by: qwen-code-dev-bot
---
.../controllers/permissionController.test.ts | 288 ++++++++++++++++++
.../controllers/permissionController.ts | 86 +++++-
packages/sdk-python/README.md | 33 ++
packages/sdk-typescript/README.md | 39 +++
packages/web-shell/client/test/setup.ts | 6 +
5 files changed, 437 insertions(+), 15 deletions(-)
diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts
index ec03ad28471..63de2c3cead 100644
--- a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts
+++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts
@@ -97,6 +97,200 @@ describe('PermissionController', () => {
});
});
+ it('routes ask_user_question answers from updatedInput into the confirmation payload', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ const answers = { '0': 'PostgreSQL', '1': 'REST' };
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'request-answers',
+ response: {
+ behavior: 'allow',
+ updatedInput: { questions: [], answers },
+ },
+ });
+ const onConfirm = vi.fn();
+ const toolCall = {
+ status: 'awaiting_approval',
+ request: {
+ callId: 'tool-call-answers',
+ name: 'ask_user_question',
+ args: { questions: [] } as Record,
+ },
+ confirmationDetails: {
+ type: 'ask_user_question',
+ title: 'Please answer',
+ onConfirm,
+ },
+ };
+
+ controller.getToolCallUpdateCallback()([toolCall]);
+
+ await vi.waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ expect.objectContaining({ answers }),
+ );
+ });
+
+ // The leader path overrides the tool's in-process args with the
+ // host's sanitized updatedInput before confirming.
+ expect(toolCall.request.args).toEqual({ questions: [], answers });
+ });
+
+ it('omits answers from the payload when updatedInput has none', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'request-no-answers',
+ response: {
+ behavior: 'allow',
+ updatedInput: { command: 'ls -a' },
+ },
+ });
+ const onConfirm = vi.fn();
+
+ controller.getToolCallUpdateCallback()([
+ {
+ status: 'awaiting_approval',
+ request: {
+ callId: 'tool-call-no-answers',
+ name: 'run_shell_command',
+ args: { command: 'ls' },
+ },
+ confirmationDetails: {
+ type: 'exec',
+ title: 'Run command',
+ onConfirm,
+ },
+ },
+ ]);
+
+ await vi.waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ { updatedInput: { command: 'ls -a' } },
+ );
+ });
+ });
+
+ it('does not promote a same-named answers field for non-ask_user_question tools', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'request-foreign-answers',
+ response: {
+ behavior: 'allow',
+ // A non-ask_user_question tool happens to carry an `answers` field;
+ // it must not leak into the confirmation payload.
+ updatedInput: { command: 'ls', answers: { '0': 'leak' } },
+ },
+ });
+ const onConfirm = vi.fn();
+
+ controller.getToolCallUpdateCallback()([
+ {
+ status: 'awaiting_approval',
+ request: {
+ callId: 'tool-call-foreign-answers',
+ name: 'run_shell_command',
+ args: { command: 'ls' },
+ },
+ confirmationDetails: {
+ type: 'exec',
+ title: 'Run command',
+ onConfirm,
+ },
+ },
+ ]);
+
+ await vi.waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ { updatedInput: { command: 'ls', answers: { '0': 'leak' } } },
+ );
+ });
+ expect(onConfirm).not.toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ expect.objectContaining({ answers: expect.anything() }),
+ );
+ });
+
+ it.each([
+ ['updatedInput is an array', ['ls'], undefined],
+ ['updatedInput is a string', 'ls', undefined],
+ ['answers is an array', { questions: [], answers: ['x'] }, undefined],
+ ['answers is null', { questions: [], answers: null }, undefined],
+ ['answers is an empty object', { questions: [], answers: {} }, {}],
+ ])(
+ 'omits answers from the payload when %s',
+ async (_desc, updatedInput, expectedAnswers) => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'request-guard',
+ response: { behavior: 'allow', updatedInput },
+ });
+ const onConfirm = vi.fn();
+
+ controller.getToolCallUpdateCallback()([
+ {
+ status: 'awaiting_approval',
+ request: {
+ callId: 'tool-call-guard',
+ name: 'ask_user_question',
+ args: { questions: [] },
+ },
+ confirmationDetails: {
+ type: 'ask_user_question',
+ title: 'Please answer',
+ onConfirm,
+ },
+ },
+ ]);
+
+ await vi.waitFor(() => {
+ expect(onConfirm).toHaveBeenCalled();
+ });
+
+ const [outcome, payload] = onConfirm.mock.calls[0];
+ expect(outcome).toBe(ToolConfirmationOutcome.ProceedOnce);
+ const isPlainObject =
+ updatedInput !== null &&
+ typeof updatedInput === 'object' &&
+ !Array.isArray(updatedInput);
+ if (!isPlainObject) {
+ // A non-object updatedInput (array or primitive) is rejected
+ // wholesale — plain confirm, no payload.
+ expect(payload).toBeUndefined();
+ } else if (expectedAnswers === undefined) {
+ expect(payload).toEqual({ updatedInput });
+ expect(payload).not.toHaveProperty('answers');
+ } else {
+ expect(payload).toEqual({ updatedInput, answers: expectedAnswers });
+ }
+ },
+ );
+
it('uses default timeout when SDK canUseTool timeout is undefined', async () => {
const context = createContext(); // undefined timeout
const controller = new PermissionController(
@@ -184,6 +378,100 @@ describe('PermissionController', () => {
});
});
+ it('forwards ask_user_question answers to a teammate approval', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ const answers = { '0': 'PostgreSQL', '1': 'REST' };
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'teammate-request',
+ response: {
+ behavior: 'allow',
+ updatedInput: { questions: [], answers },
+ },
+ });
+ const respond = vi.fn().mockResolvedValue(undefined);
+
+ await controller.handleTeammateApproval({
+ teammateName: 'worker',
+ toolName: 'ask_user_question',
+ toolInput: { questions: [] },
+ respond,
+ timestamp: 123,
+ });
+
+ expect(respond).toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ expect.objectContaining({ answers }),
+ );
+ });
+
+ it('does not promote a same-named answers field for a non-ask_user_question teammate approval', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'teammate-request-foreign',
+ response: {
+ behavior: 'allow',
+ updatedInput: { command: 'ls', answers: { '0': 'leak' } },
+ },
+ });
+ const respond = vi.fn().mockResolvedValue(undefined);
+
+ await controller.handleTeammateApproval({
+ teammateName: 'worker',
+ toolName: 'run_shell_command',
+ toolInput: { command: 'ls' },
+ respond,
+ timestamp: 456,
+ });
+
+ expect(respond).toHaveBeenCalledWith(ToolConfirmationOutcome.ProceedOnce, {
+ updatedInput: { command: 'ls', answers: { '0': 'leak' } },
+ });
+ expect(respond).not.toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ expect.objectContaining({ answers: expect.anything() }),
+ );
+ });
+
+ it('confirms a teammate approval with no payload when updatedInput is absent', async () => {
+ const context = createContext(120_000);
+ const controller = new PermissionController(
+ context,
+ createRegistry(),
+ 'PermissionController',
+ );
+ vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({
+ subtype: 'success',
+ request_id: 'teammate-request-no-input',
+ response: { behavior: 'allow' },
+ });
+ const respond = vi.fn().mockResolvedValue(undefined);
+
+ await controller.handleTeammateApproval({
+ teammateName: 'worker',
+ toolName: 'run_shell_command',
+ toolInput: { command: 'ls' },
+ respond,
+ timestamp: 789,
+ });
+
+ expect(respond).toHaveBeenCalledWith(
+ ToolConfirmationOutcome.ProceedOnce,
+ undefined,
+ );
+ });
+
it('omits modify suggestions when edit confirmation hides modify actions', () => {
const controller = new PermissionController(
createContext(),
diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts
index 4474554eed0..a3179f2c8c4 100644
--- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts
+++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts
@@ -25,6 +25,7 @@ import type {
import {
InputFormat,
ToolConfirmationOutcome,
+ ToolNames,
} from '@qwen-code/qwen-code-core';
import type {
CLIControlPermissionRequest,
@@ -388,6 +389,44 @@ export class PermissionController extends BaseController {
};
}
+ /**
+ * Build the confirmation payload for an approved (`allow`) tool call.
+ *
+ * `updatedInput` carries the (possibly sanitised) tool args the host
+ * wants executed. For `ask_user_question` the host also delivers the
+ * user's answers on this channel as `updatedInput.answers`; those
+ * answers must reach the tool via `payload.answers` (the tool reads
+ * them from there, not from its args). Answers are promoted only for
+ * `ask_user_question`, so a same-named `answers` field on any other
+ * tool's input can never leak into the confirmation payload.
+ *
+ * Returns `undefined` when the host sent no usable `updatedInput`, so
+ * callers fall back to a plain single-argument confirmation.
+ */
+ private buildAllowConfirmationPayload(
+ toolName: string,
+ updatedInput: unknown,
+ ): ToolConfirmationPayload | undefined {
+ if (
+ !updatedInput ||
+ typeof updatedInput !== 'object' ||
+ Array.isArray(updatedInput)
+ ) {
+ return undefined;
+ }
+ const updatedInputObj = updatedInput as Record;
+ const answers =
+ toolName === ToolNames.ASK_USER_QUESTION
+ ? updatedInputObj['answers']
+ : undefined;
+ return {
+ updatedInput: updatedInputObj,
+ ...(answers && typeof answers === 'object' && !Array.isArray(answers)
+ ? { answers: answers as Record }
+ : {}),
+ };
+ }
+
/**
* Handle a teammate tool approval request routed via the
* TEAMMATE_APPROVAL_REQUEST team event. Stream-json only —
@@ -447,14 +486,13 @@ export class PermissionController extends BaseController {
// args and the host's policy is silently bypassed. The
// leader's same-process path mutates `request.args`
// directly; teammates can't reach across process so the
- // payload carries the override instead.
- const updatedInput = payload['updatedInput'];
- const respondPayload: ToolConfirmationPayload | undefined =
- updatedInput &&
- typeof updatedInput === 'object' &&
- !Array.isArray(updatedInput)
- ? { updatedInput: updatedInput as Record }
- : undefined;
+ // payload carries the override instead. For
+ // `ask_user_question` this same payload also carries the
+ // user's answers, mirroring the leader path.
+ const respondPayload = this.buildAllowConfirmationPayload(
+ event.toolName,
+ payload['updatedInput'],
+ );
await event.respond(
ToolConfirmationOutcome.ProceedOnce,
respondPayload,
@@ -555,14 +593,32 @@ export class PermissionController extends BaseController {
const behavior = String(payload['behavior'] || '').toLowerCase();
if (behavior === 'allow') {
- // Handle updated input if provided
- const updatedInput = payload['updatedInput'];
- if (updatedInput && typeof updatedInput === 'object') {
- toolCall.request.args = updatedInput as Record;
- }
- await toolCall.confirmationDetails.onConfirm(
- ToolConfirmationOutcome.ProceedOnce,
+ // Handle updated input if provided. The SDK's `can_use_tool`
+ // callback returns `updatedInput` — the (possibly sanitised)
+ // tool args the host wants executed. For most tools this simply
+ // overrides `request.args`. For `ask_user_question` the host also
+ // uses this channel to deliver the user's answers: it returns
+ // `{ ...originalInput, answers }`, and those answers must reach the
+ // tool via the confirmation payload (`payload.answers`) — the tool
+ // reads answers from there, not from `request.args`.
+ const confirmationPayload = this.buildAllowConfirmationPayload(
+ toolCall.request.name,
+ payload['updatedInput'],
);
+
+ if (confirmationPayload) {
+ // Override the tool's args in-process with the host's
+ // sanitised input before confirming.
+ toolCall.request.args = confirmationPayload.updatedInput ?? {};
+ await toolCall.confirmationDetails.onConfirm(
+ ToolConfirmationOutcome.ProceedOnce,
+ confirmationPayload,
+ );
+ } else {
+ await toolCall.confirmationDetails.onConfirm(
+ ToolConfirmationOutcome.ProceedOnce,
+ );
+ }
} else {
// Extract cancel message from response if available
const cancelMessage =
diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md
index 3f324a42129..6deca2011f4 100644
--- a/packages/sdk-python/README.md
+++ b/packages/sdk-python/README.md
@@ -286,6 +286,39 @@ The `context` argument includes `cancel_event`, `suggestions`, and
`can_use_tool` must be an `async def` callback accepting
`(tool_name, tool_input, context)`. `stderr` must accept a single `str`.
+### Handling `ask_user_question`
+
+When the model needs a decision from the user it calls the built-in
+`ask_user_question` tool. This flows through the same `can_use_tool`
+callback: `tool_input` carries a `questions` list, and you return the
+collected answers via `updatedInput["answers"]`. `answers` is a dict keyed
+by the question's index (as a string), where each value is the label of the
+chosen option (or free-form text when the user picks "Other").
+
+```python
+async def can_use_tool(tool_name, tool_input, context):
+ if tool_name == "ask_user_question":
+ questions = tool_input["questions"]
+
+ # Present the questions to the user however your app sees fit,
+ # then build an index-keyed map of their answers.
+ answers = {}
+ for index, question in enumerate(questions):
+ answers[str(index)] = await prompt_user_to_choose(question)
+
+ # Return the answers through updatedInput["answers"] — the CLI
+ # forwards them to the tool so the model receives the decisions.
+ return {
+ "behavior": "allow",
+ "updatedInput": {**tool_input, "answers": answers},
+ }
+
+ return {"behavior": "allow", "updatedInput": tool_input}
+```
+
+If you return `allow` without any `answers`, the tool reports that no answer
+was provided; return `deny` to signal the user declined.
+
## Runtime Controls
Control methods can be called while a session is active:
diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md
index 027e697e71c..43896be759d 100644
--- a/packages/sdk-typescript/README.md
+++ b/packages/sdk-typescript/README.md
@@ -314,6 +314,45 @@ const result = query({
});
```
+### Handling `ask_user_question`
+
+When the model needs a decision from the user it calls the built-in
+`ask_user_question` tool. The SDK surfaces this through the same
+`canUseTool` callback: the tool input contains a `questions` array, and you
+return the collected answers via `updatedInput.answers`. `answers` is an
+object keyed by the question's index (as a string), where each value is the
+label of the chosen option (or free-form text when the user picks "Other").
+
+```typescript
+import { query, type CanUseTool } from '@qwen-code/sdk';
+
+const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
+ if (toolName === 'ask_user_question') {
+ const questions = input.questions as Array<{
+ question: string;
+ header: string;
+ options: Array<{ label: string; description: string }>;
+ }>;
+
+ // Present the questions to the user however your app sees fit, then
+ // build an index-keyed map of their answers.
+ const answers: Record = {};
+ for (let i = 0; i < questions.length; i++) {
+ answers[String(i)] = await promptUserToChoose(questions[i]);
+ }
+
+ // Return the answers through `updatedInput.answers` — the CLI forwards
+ // them to the tool so the model receives the user's decisions.
+ return { behavior: 'allow', updatedInput: { ...input, answers } };
+ }
+
+ return { behavior: 'allow', updatedInput: input };
+};
+```
+
+> If you return `allow` without any `answers`, the tool reports that no
+> answer was provided; return `deny` to signal the user declined.
+
### With External MCP Servers
```typescript
diff --git a/packages/web-shell/client/test/setup.ts b/packages/web-shell/client/test/setup.ts
index ace65e79f56..32d6513d999 100644
--- a/packages/web-shell/client/test/setup.ts
+++ b/packages/web-shell/client/test/setup.ts
@@ -50,6 +50,12 @@ if (
globalWithDom.Element.prototype.scrollIntoView = () => {};
}
+// jsdom implements getClientRects()/getBoundingClientRect() on Element but not
+// on Range. CodeMirror's async measure pass (scheduled via requestAnimationFrame)
+// calls them on a text Range, so without this stub it throws
+// "textRange(...).getClientRects is not a function" from a rAF callback after a
+// test has completed — an unhandled error that flakes the whole run even though
+// every assertion passed.
if (typeof globalWithDom.Range !== 'undefined') {
const rangePrototype = globalWithDom.Range.prototype as Range & {
getBoundingClientRect?: () => DOMRect;
From 5ac64460b1668346592ee7bcf79fb785d9706b6b Mon Sep 17 00:00:00 2001
From: han <2992336417@qq.com>
Date: Sat, 11 Jul 2026 08:07:03 +0800
Subject: [PATCH 08/11] fix(cli): localize approval mode UI labels (#6592)
* fix(cli): localize approval mode UI labels
* fix(cli): address approval mode i18n review
* fix(cli): stabilize approval mode i18n key
* test(cli): cover approval mode i18n follow-up
* test(cli): cover localized auto indicator
* test(cli): address approval i18n suggestions
---------
Co-authored-by: Shaojin Wen
---
packages/cli/src/i18n/locales/ca.js | 2 +
packages/cli/src/i18n/locales/de.js | 2 +
packages/cli/src/i18n/locales/en.js | 2 +
packages/cli/src/i18n/locales/fr.js | 2 +
packages/cli/src/i18n/locales/ja.js | 4 +-
packages/cli/src/i18n/locales/pt.js | 2 +
packages/cli/src/i18n/locales/ru.js | 2 +
packages/cli/src/i18n/locales/zh-TW.js | 4 +-
packages/cli/src/i18n/locales/zh.js | 4 +-
packages/cli/src/i18n/mustTranslateKeys.ts | 1 +
.../ui/commands/approvalModeCommand.test.ts | 2 +-
.../components/AutoAcceptIndicator.test.tsx | 19 ++-
.../src/ui/components/AutoAcceptIndicator.tsx | 2 +-
.../ui/hooks/useAutoAcceptIndicator.test.ts | 134 +++++++++++++++++-
.../src/ui/hooks/useAutoAcceptIndicator.ts | 15 +-
.../src/ui/utils/approvalModeDisplay.test.ts | 53 +++++--
.../cli/src/ui/utils/approvalModeDisplay.ts | 8 +-
scripts/check-i18n.ts | 16 ++-
scripts/tests/check-i18n.test.ts | 30 ++++
19 files changed, 283 insertions(+), 21 deletions(-)
diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js
index 5d062e27d23..18d73219d55 100644
--- a/packages/cli/src/i18n/locales/ca.js
+++ b/packages/cli/src/i18n/locales/ca.js
@@ -23,6 +23,8 @@ export default {
'Shell mode': 'Mode shell',
'YOLO mode': 'Mode YOLO',
'Auto mode': 'Mode auto',
+ 'auto_mode.entry_notice':
+ "Mode auto activat.\n Un classificador LLM avalua cada crida d'eina — les accions segures s'aproven automàticament,\n les arriscades es bloquegen. Sortiu: Shift+Tab o /approval-mode default.",
'plan mode': 'mode de planificació',
'auto-accept edits': 'acceptació automàtica de canvis',
'Accepting edits': 'Acceptant canvis',
diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js
index 0fd0c0c4dbd..de5c0e28e41 100644
--- a/packages/cli/src/i18n/locales/de.js
+++ b/packages/cli/src/i18n/locales/de.js
@@ -25,6 +25,8 @@ export default {
'Shell mode': 'Shell-Modus',
'YOLO mode': 'YOLO-Modus',
'Auto mode': 'Auto-Modus',
+ 'auto_mode.entry_notice':
+ 'Auto-Modus aktiviert.\n Ein LLM-Klassifikator bewertet jeden Tool-Aufruf — sichere Aktionen werden automatisch genehmigt,\n riskante werden blockiert. Beenden: Shift+Tab oder /approval-mode default.',
'plan mode': 'Planungsmodus',
'auto-accept edits': 'Änderungen automatisch akzeptieren',
'Accepting edits': 'Änderungen werden akzeptiert',
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index b85c6007a69..97c928ffa1b 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -233,6 +233,8 @@ export default {
'Shell mode': 'Shell mode',
'YOLO mode': 'YOLO mode',
'Auto mode': 'Auto mode',
+ 'auto_mode.entry_notice':
+ 'Auto mode enabled.\n An LLM classifier evaluates each tool call — safe actions auto-approve,\n risky ones are blocked. Exit: Shift+Tab or /approval-mode default.',
'plan mode': 'plan mode',
'auto-accept edits': 'auto-accept edits',
'Accepting edits': 'Accepting edits',
diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js
index 7b9806dd140..b51a965a24b 100644
--- a/packages/cli/src/i18n/locales/fr.js
+++ b/packages/cli/src/i18n/locales/fr.js
@@ -23,6 +23,8 @@ export default {
'Shell mode': 'Mode shell',
'YOLO mode': 'Mode YOLO',
'Auto mode': 'Mode auto',
+ 'auto_mode.entry_notice':
+ "Mode auto activé.\n Un classificateur LLM évalue chaque appel d'outil — les actions sûres sont approuvées automatiquement,\n les actions risquées sont bloquées. Quitter : Shift+Tab ou /approval-mode default.",
'plan mode': 'mode plan',
'auto-accept edits': 'acceptation automatique des modifications',
'Accepting edits': 'Acceptation des modifications',
diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js
index 3e5bf29c41e..eb6a74ac410 100644
--- a/packages/cli/src/i18n/locales/ja.js
+++ b/packages/cli/src/i18n/locales/ja.js
@@ -18,7 +18,9 @@ export default {
'@src/myFile.ts': '@src/myFile.ts',
'Shell mode': 'シェルモード',
'YOLO mode': 'YOLOモード',
- 'Auto mode': 'Autoモード',
+ 'Auto mode': '自動モード',
+ 'auto_mode.entry_notice':
+ '自動モードが有効です。\n LLM 分類器が各ツール呼び出しを評価します — 安全な操作は自動承認され、\n 危険な操作はブロックされます。終了: Shift+Tab または /approval-mode default。',
'plan mode': 'プランモード',
'auto-accept edits': '編集を自動承認',
'Accepting edits': '編集を承認中',
diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js
index 1b952d8c8cb..998cbdf6a1b 100644
--- a/packages/cli/src/i18n/locales/pt.js
+++ b/packages/cli/src/i18n/locales/pt.js
@@ -19,6 +19,8 @@ export default {
'Shell mode': 'Modo shell',
'YOLO mode': 'Modo YOLO',
'Auto mode': 'Modo auto',
+ 'auto_mode.entry_notice':
+ 'Modo auto ativado.\n Um classificador LLM avalia cada chamada de ferramenta — ações seguras são aprovadas automaticamente,\n ações arriscadas são bloqueadas. Sair: Shift+Tab ou /approval-mode default.',
'plan mode': 'modo planejamento',
'auto-accept edits': 'aceitar edições automaticamente',
'Accepting edits': 'Aceitando edições',
diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js
index 55f48a196c2..caa64dbaa3a 100644
--- a/packages/cli/src/i18n/locales/ru.js
+++ b/packages/cli/src/i18n/locales/ru.js
@@ -25,6 +25,8 @@ export default {
'Shell mode': 'Режим терминала',
'YOLO mode': 'Режим YOLO',
'Auto mode': 'Автоматический режим',
+ 'auto_mode.entry_notice':
+ 'Автоматический режим включен.\n LLM-классификатор оценивает каждый вызов инструмента — безопасные действия утверждаются автоматически,\n рискованные блокируются. Выход: Shift+Tab или /approval-mode default.',
'plan mode': 'Режим планирования',
'auto-accept edits': 'Режим принятия правок',
'Accepting edits': 'Принятие правок',
diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js
index 469fd486d2f..18396661f4c 100644
--- a/packages/cli/src/i18n/locales/zh-TW.js
+++ b/packages/cli/src/i18n/locales/zh-TW.js
@@ -219,7 +219,9 @@ export default {
'@src/myFile.ts': '@src/myFile.ts',
'Shell mode': 'Shell 模式',
'YOLO mode': 'YOLO 模式',
- 'Auto mode': 'Auto 模式',
+ 'Auto mode': '自動模式',
+ 'auto_mode.entry_notice':
+ '已啟用自動模式。\n LLM 分類器會評估每次工具呼叫 — 安全操作將自動批准,\n 有風險的操作將被阻止。退出:Shift+Tab 或 /approval-mode default。',
'plan mode': '規劃模式',
'auto-accept edits': '自動接受編輯',
'Accepting edits': '接受編輯',
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 826a2a6f688..747cfb87ea8 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -223,7 +223,9 @@ export default {
'@src/myFile.ts': '@src/myFile.ts',
'Shell mode': 'Shell 模式',
'YOLO mode': 'YOLO 模式',
- 'Auto mode': 'Auto 模式',
+ 'Auto mode': '自动模式',
+ 'auto_mode.entry_notice':
+ '已启用自动模式。\n LLM 分类器会评估每次工具调用 — 安全操作将自动批准,\n 有风险的操作将被阻止。退出:Shift+Tab 或 /approval-mode default。',
'plan mode': '规划模式',
'auto-accept edits': '自动接受编辑',
'Accepting edits': '接受编辑',
diff --git a/packages/cli/src/i18n/mustTranslateKeys.ts b/packages/cli/src/i18n/mustTranslateKeys.ts
index cfb6b7538cf..71fde07627c 100644
--- a/packages/cli/src/i18n/mustTranslateKeys.ts
+++ b/packages/cli/src/i18n/mustTranslateKeys.ts
@@ -94,6 +94,7 @@ export const MUST_TRANSLATE_KEYS = [
'? for shortcuts',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
'Approval mode set to "{{mode}}"',
+ 'auto_mode.entry_notice',
"Set up Qwen Code's status line UI",
'Cached (included in Input): {{tokens}}',
'By source:',
diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts
index 77fbe2b4d93..ebe78d6f053 100644
--- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts
+++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts
@@ -91,7 +91,7 @@ describe('approvalModeCommand', () => {
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
- expect(result.content).toContain('auto-edit');
+ expect(result.content).toContain('auto-accept edits');
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
});
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
index e9f58f49ad7..e486bb353a7 100644
--- a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
@@ -8,6 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
+import { setLanguageAsync } from '../../i18n/index.js';
describe('', () => {
it('renders DEFAULT mode with pause badge and Ask permissions text', () => {
@@ -33,11 +34,25 @@ describe('', () => {
expect(lastFrame()).toContain('auto-accept edits');
});
- it('renders AUTO mode indicator', () => {
+ it('renders AUTO mode indicator with the localized label', () => {
const { lastFrame } = render(
,
);
- expect(lastFrame()).toContain('auto mode (classifier-evaluated)');
+ const frame = lastFrame()!;
+ expect(frame).toContain('Auto mode');
+ expect(frame).not.toContain('auto mode (classifier-evaluated)');
+ });
+
+ it('renders AUTO mode indicator with the active Chinese locale', async () => {
+ await setLanguageAsync('zh');
+ try {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('自动模式');
+ } finally {
+ await setLanguageAsync('en');
+ }
});
it('renders YOLO mode indicator', () => {
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
index caa9234c177..6b1a851c59d 100644
--- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
@@ -37,7 +37,7 @@ export const AutoAcceptIndicator: React.FC = ({
subText = cycleText;
break;
case ApprovalMode.AUTO:
- textContent = t('auto mode (classifier-evaluated)');
+ textContent = t('Auto mode');
subText = cycleText;
break;
case ApprovalMode.YOLO:
diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
index 30d34677179..56aa6436ffa 100644
--- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
+++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
@@ -14,13 +14,18 @@ import {
type Mock,
} from 'vitest';
import { renderHook, act } from '@testing-library/react';
-import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
+import {
+ emitAutoModeEntryNotices,
+ useAutoAcceptIndicator,
+} from './useAutoAcceptIndicator.js';
import { Config, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { Config as ActualConfigType } from '@qwen-code/qwen-code-core';
import type { Key } from './useKeypress.js';
import { useKeypress } from './useKeypress.js';
import { MessageType } from '../types.js';
+import { setLanguage, setLanguageAsync } from '../../i18n/index.js';
+import { SettingScope, type LoadedSettings } from '../../config/settings.js';
vi.mock('./useKeypress.js');
@@ -55,6 +60,13 @@ interface MockConfigInstanceShape {
type UseKeypressHandler = (key: Key) => void;
+function createMockSettings(autoModeAcknowledged: boolean): LoadedSettings {
+ return {
+ merged: { ui: { autoModeAcknowledged } },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+}
+
describe('useAutoAcceptIndicator', () => {
let mockConfigInstance: MockConfigInstanceShape;
let capturedUseKeypressHandler: UseKeypressHandler;
@@ -494,6 +506,126 @@ describe('useAutoAcceptIndicator', () => {
);
});
+ it('should emit the localizable AUTO mode entry notice', async () => {
+ await setLanguageAsync('en');
+ const mockAddItem = vi.fn();
+
+ emitAutoModeEntryNotices({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ addItem: mockAddItem,
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text:
+ 'Auto mode enabled.\n' +
+ ' An LLM classifier evaluates each tool call — safe actions auto-approve,\n' +
+ ' risky ones are blocked. Exit: Shift+Tab or /approval-mode default.',
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('should fall back to readable English when the entry notice key is not loaded', async () => {
+ const stderrWrite = vi
+ .spyOn(process.stderr, 'write')
+ .mockImplementation(() => true);
+ setLanguage('ca');
+ try {
+ const mockAddItem = vi.fn();
+
+ emitAutoModeEntryNotices({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ addItem: mockAddItem,
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text:
+ 'Auto mode enabled.\n' +
+ ' An LLM classifier evaluates each tool call — safe actions auto-approve,\n' +
+ ' risky ones are blocked. Exit: Shift+Tab or /approval-mode default.',
+ },
+ expect.any(Number),
+ );
+ } finally {
+ stderrWrite.mockRestore();
+ await setLanguageAsync('en');
+ }
+ });
+
+ it('should persist acknowledgement after emitting the AUTO mode entry notice', async () => {
+ await setLanguageAsync('en');
+ const mockAddItem = vi.fn();
+ const mockSettings = createMockSettings(false);
+
+ emitAutoModeEntryNotices({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ settings: mockSettings,
+ addItem: mockAddItem,
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: expect.stringContaining('Auto mode enabled.'),
+ }),
+ expect.any(Number),
+ );
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'ui.autoModeAcknowledged',
+ true,
+ );
+ });
+
+ it('should skip the AUTO mode entry notice after acknowledgement', async () => {
+ await setLanguageAsync('en');
+ const mockAddItem = vi.fn();
+ const mockSettings = createMockSettings(true);
+
+ emitAutoModeEntryNotices({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ settings: mockSettings,
+ addItem: mockAddItem,
+ });
+
+ expect(mockAddItem).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: expect.stringContaining('Auto mode enabled.'),
+ }),
+ expect.any(Number),
+ );
+ expect(mockSettings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('should emit the AUTO mode entry notice with the active locale', async () => {
+ await setLanguageAsync('zh');
+ try {
+ const mockAddItem = vi.fn();
+
+ emitAutoModeEntryNotices({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ addItem: mockAddItem,
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text:
+ '已启用自动模式。\n' +
+ ' LLM 分类器会评估每次工具调用 — 安全操作将自动批准,\n' +
+ ' 有风险的操作将被阻止。退出:Shift+Tab 或 /approval-mode default。',
+ },
+ expect.any(Number),
+ );
+ } finally {
+ await setLanguageAsync('en');
+ }
+ });
+
it('should not cycle approval mode on Windows when shouldBlockTab returns true', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
index 7e2e5b3bb95..4a681fd7e01 100644
--- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
+++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
@@ -14,12 +14,21 @@ import { useKeypress } from './useKeypress.js';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
+import { t } from '../../i18n/index.js';
-const AUTO_MODE_FIRST_TIME_MESSAGE =
+const AUTO_MODE_FIRST_TIME_MESSAGE_KEY = 'auto_mode.entry_notice';
+const AUTO_MODE_FIRST_TIME_MESSAGE_FALLBACK =
'Auto mode enabled.\n' +
' An LLM classifier evaluates each tool call — safe actions auto-approve,\n' +
' risky ones are blocked. Exit: Shift+Tab or /approval-mode default.';
+const getAutoModeFirstTimeMessage = () => {
+ const message = t(AUTO_MODE_FIRST_TIME_MESSAGE_KEY);
+ return message === AUTO_MODE_FIRST_TIME_MESSAGE_KEY
+ ? AUTO_MODE_FIRST_TIME_MESSAGE_FALLBACK
+ : message;
+};
+
export interface UseAutoAcceptIndicatorArgs {
config: Config;
/** Settings handle — used to read/write `ui.autoModeAcknowledged`. */
@@ -145,7 +154,7 @@ export function emitAutoModeEntryNotices(opts: {
const acknowledged = settings?.merged.ui?.autoModeAcknowledged === true;
if (!acknowledged) {
addItem(
- { type: MessageType.INFO, text: AUTO_MODE_FIRST_TIME_MESSAGE },
+ { type: MessageType.INFO, text: getAutoModeFirstTimeMessage() },
now,
);
if (settings) {
@@ -165,6 +174,8 @@ export function emitAutoModeEntryNotices(opts: {
stripped &&
(stripped.persistent.length > 0 || stripped.session.length > 0)
) {
+ // Intentionally untranslated operational notice: rule text is copied from
+ // user/session allow-rule configuration and may contain command syntax.
const lines = [
'ℹ Auto mode temporarily disabled these allow rules',
' (they would bypass the classifier):',
diff --git a/packages/cli/src/ui/utils/approvalModeDisplay.test.ts b/packages/cli/src/ui/utils/approvalModeDisplay.test.ts
index 52436927dfd..fd3819f0a00 100644
--- a/packages/cli/src/ui/utils/approvalModeDisplay.test.ts
+++ b/packages/cli/src/ui/utils/approvalModeDisplay.test.ts
@@ -10,23 +10,35 @@ import {
formatApprovalModeDescription,
formatApprovalModeName,
} from './approvalModeDisplay.js';
+import { setLanguageAsync } from '../../i18n/index.js';
describe('approval mode display', () => {
describe('formatApprovalModeName', () => {
- it('formats yolo as uppercase', () => {
- expect(formatApprovalModeName(ApprovalMode.YOLO)).toBe('YOLO');
- });
-
- it('formats default mode as a friendly name', () => {
+ it('formats all modes as friendly names', () => {
+ expect(formatApprovalModeName(ApprovalMode.PLAN)).toBe('plan mode');
expect(formatApprovalModeName(ApprovalMode.DEFAULT)).toBe(
'Ask permissions',
);
+ expect(formatApprovalModeName(ApprovalMode.AUTO_EDIT)).toBe(
+ 'auto-accept edits',
+ );
+ expect(formatApprovalModeName(ApprovalMode.AUTO)).toBe('Auto mode');
+ expect(formatApprovalModeName(ApprovalMode.YOLO)).toBe('YOLO mode');
});
- it('falls back to the raw mode value for modes without a custom name', () => {
- expect(formatApprovalModeName(ApprovalMode.PLAN)).toBe('plan');
- expect(formatApprovalModeName(ApprovalMode.AUTO_EDIT)).toBe('auto-edit');
- expect(formatApprovalModeName(ApprovalMode.AUTO)).toBe('auto');
+ it('formats mode names with the active locale', async () => {
+ await setLanguageAsync('zh');
+ try {
+ expect(formatApprovalModeName(ApprovalMode.PLAN)).toBe('规划模式');
+ expect(formatApprovalModeName(ApprovalMode.DEFAULT)).toBe('请求授权');
+ expect(formatApprovalModeName(ApprovalMode.AUTO_EDIT)).toBe(
+ '自动接受编辑',
+ );
+ expect(formatApprovalModeName(ApprovalMode.AUTO)).toBe('自动模式');
+ expect(formatApprovalModeName(ApprovalMode.YOLO)).toBe('YOLO 模式');
+ } finally {
+ await setLanguageAsync('en');
+ }
});
});
@@ -51,5 +63,28 @@ describe('approval mode display', () => {
'Automatically approve all tools',
);
});
+
+ it('formats descriptions with the active locale', async () => {
+ await setLanguageAsync('zh');
+ try {
+ expect(formatApprovalModeDescription(ApprovalMode.PLAN)).toBe(
+ '仅分析,不修改文件或执行命令',
+ );
+ expect(formatApprovalModeDescription(ApprovalMode.DEFAULT)).toBe(
+ '需要批准文件编辑或 shell 命令',
+ );
+ expect(formatApprovalModeDescription(ApprovalMode.AUTO_EDIT)).toBe(
+ '自动批准文件编辑',
+ );
+ expect(formatApprovalModeDescription(ApprovalMode.AUTO)).toBe(
+ '使用分类器自动批准安全的工具调用',
+ );
+ expect(formatApprovalModeDescription(ApprovalMode.YOLO)).toBe(
+ '自动批准所有工具',
+ );
+ } finally {
+ await setLanguageAsync('en');
+ }
+ });
});
});
diff --git a/packages/cli/src/ui/utils/approvalModeDisplay.ts b/packages/cli/src/ui/utils/approvalModeDisplay.ts
index 7c61dfe8f7f..08255166d76 100644
--- a/packages/cli/src/ui/utils/approvalModeDisplay.ts
+++ b/packages/cli/src/ui/utils/approvalModeDisplay.ts
@@ -9,10 +9,16 @@ import { t } from '../../i18n/index.js';
export function formatApprovalModeName(mode: ApprovalMode): string {
switch (mode) {
+ case ApprovalMode.PLAN:
+ return t('plan mode');
case ApprovalMode.DEFAULT:
return t('Ask permissions');
+ case ApprovalMode.AUTO_EDIT:
+ return t('auto-accept edits');
+ case ApprovalMode.AUTO:
+ return t('Auto mode');
case ApprovalMode.YOLO:
- return 'YOLO';
+ return t('YOLO mode');
default:
return mode;
}
diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts
index eb9109e6ecb..d597c813be4 100644
--- a/scripts/check-i18n.ts
+++ b/scripts/check-i18n.ts
@@ -63,6 +63,11 @@ export interface PrintCheckI18nOptions {
const __dirname = dirname(fileURLToPath(import.meta.url));
const WRITE_UNUSED_KEYS_FLAG = '--write-unused-locale-keys';
const WRITE_UNUSED_KEYS_ENV = 'QWEN_CHECK_I18N_WRITE_UNUSED_KEYS';
+const EN_SEMANTIC_KEY_EXCEPTIONS: ReadonlySet = new Set([
+ // Multi-line copy is intentionally keyed by a stable identifier so wording
+ // changes do not silently break locale lookup.
+ 'auto_mode.entry_notice',
+]);
export function shouldWriteUnusedKeysJson(): boolean {
return (
@@ -288,7 +293,7 @@ function checkKeyValueConsistency(enTranslations: TranslationDict): string[] {
continue;
}
- if (key !== value) {
+ if (key !== value && !EN_SEMANTIC_KEY_EXCEPTIONS.has(key)) {
errors.push(`Key-value mismatch in en.js: "${key}" !== "${value}"`);
}
}
@@ -414,6 +419,15 @@ export async function checkI18n(
.map((language) => language.code),
);
+ const builtinMustTranslateKeySet = new Set(MUST_TRANSLATE_KEYS);
+ for (const key of EN_SEMANTIC_KEY_EXCEPTIONS) {
+ if (!builtinMustTranslateKeySet.has(key)) {
+ errors.push(
+ `English semantic key exception must be listed in MUST_TRANSLATE_KEYS: "${key}"`,
+ );
+ }
+ }
+
const localeDefinitions = supportedLanguages.map((language) => ({
code: language.code,
id: language.id,
diff --git a/scripts/tests/check-i18n.test.ts b/scripts/tests/check-i18n.test.ts
index 4069f265ef9..d6e7eda144f 100644
--- a/scripts/tests/check-i18n.test.ts
+++ b/scripts/tests/check-i18n.test.ts
@@ -274,6 +274,36 @@ describe('checkI18n', () => {
);
});
+ it('allows the AUTO mode entry notice semantic key in en.js while preserving mismatch errors', async () => {
+ const { localesDir, sourceDir } = makeFixture();
+ writeLocale(localesDir, 'en', {
+ 'auto_mode.entry_notice': 'Auto mode enabled.',
+ OrdinaryMismatch: 'Different English copy',
+ });
+ writeLocale(localesDir, 'fr', {
+ 'auto_mode.entry_notice': 'Mode auto activé.',
+ OrdinaryMismatch: 'Texte français',
+ });
+ writeSource(
+ sourceDir,
+ "t('auto_mode.entry_notice');\nt('OrdinaryMismatch');\n",
+ );
+
+ const result = await checkI18n({
+ localesDir,
+ sourceDir,
+ supportedLanguages: languages('en', 'fr'),
+ mustTranslateKeys: ['auto_mode.entry_notice'],
+ });
+
+ expect(result.errors).not.toContain(
+ 'Key-value mismatch in en.js: "auto_mode.entry_notice" !== "Auto mode enabled."',
+ );
+ expect(result.errors).toContain(
+ 'Key-value mismatch in en.js: "OrdinaryMismatch" !== "Different English copy"',
+ );
+ });
+
it('writes unused locale-only keys only when requested', async () => {
const { root, localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
From f325d42e90439809cecfa544dfd7705b92fc1551 Mon Sep 17 00:00:00 2001
From: qqqys
Date: Sat, 11 Jul 2026 08:08:13 +0800
Subject: [PATCH 09/11] feat(dingtalk): mention response senders (#6679)
* docs: design DingTalk at-sender replies
* docs: plan DingTalk at-sender replies
* feat(channels): preserve session for response delivery
* feat(dingtalk): optionally mention response sender
* docs(dingtalk): explain response mentions
* fix(dingtalk): retain queued mention targets
* fix(dingtalk): bound mention target lifecycle
* fix(dingtalk): clear synthetic command mention target
* fix(dingtalk): clear buffered targets on session death
* debug(dingtalk): log mention delivery result
* fix(dingtalk): render response mentions
* fix(dingtalk): send visible response mentions
* feat(dingtalk): use text replies for mentions
* fix(dingtalk): preserve mentioned text replies
---
.../plans/2026-07-10-dingtalk-at-sender.md | 294 +++++++++
.../2026-07-10-dingtalk-at-sender-design.md | 55 ++
docs/users/features/channels/dingtalk.md | 3 +
.../channels/base/src/ChannelBase.test.ts | 41 ++
packages/channels/base/src/ChannelBase.ts | 17 +-
.../dingtalk/src/DingtalkAdapter.test.ts | 593 ++++++++++++++++++
.../channels/dingtalk/src/DingtalkAdapter.ts | 209 +++++-
7 files changed, 1207 insertions(+), 5 deletions(-)
create mode 100644 docs/superpowers/plans/2026-07-10-dingtalk-at-sender.md
create mode 100644 docs/superpowers/specs/2026-07-10-dingtalk-at-sender-design.md
diff --git a/docs/superpowers/plans/2026-07-10-dingtalk-at-sender.md b/docs/superpowers/plans/2026-07-10-dingtalk-at-sender.md
new file mode 100644
index 00000000000..91cb8cf68e7
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-dingtalk-at-sender.md
@@ -0,0 +1,294 @@
+# DingTalk At-Sender Replies Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a DingTalk channel optionally @ the group member whose message triggered an agent response.
+
+**Architecture:** Add a protected, session-aware response-delivery hook in `ChannelBase`; its default preserves existing adapters. The DingTalk adapter records the inbound message's staff ID, binds it to the prompt session, and only includes DingTalk's `atUserIds` on the first Markdown chunk of that response.
+
+**Tech Stack:** TypeScript, Vitest, DingTalk Stream session webhooks.
+
+## Global Constraints
+
+- Work only in `/Users/qqqys/Desktop/qys/qwen-code/.worktrees/feat-dingtalk-reply-mention` on branch `feat/dingtalk-reply-mention`.
+- `atSender` is an optional DingTalk-only boolean and defaults to `false`.
+- Mention only group agent responses with a non-empty inbound `senderStaffId`; leave DMs, local command replies, error fallbacks, and proactive sends unchanged.
+- For a multi-chunk Markdown response, include `at.atUserIds` only on the first chunk.
+- Keep TypeScript strict; do not add dependencies.
+
+---
+
+## File Structure
+
+- Modify `packages/channels/base/src/ChannelBase.ts`: route complete and block-streamed agent output through a session-aware protected hook while retaining `sendMessage(chatId, text)` for all non-agent output.
+- Modify `packages/channels/base/src/ChannelBase.test.ts`: verify block streaming supplies its session ID to the new hook.
+- Modify `packages/channels/dingtalk/src/DingtalkAdapter.ts`: parse `atSender`, correlate DingTalk message IDs to staff IDs and sessions, and add the optional Markdown `at` payload.
+- Modify `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`: assert enabled, disabled, missing-ID, and multi-chunk outbound payloads.
+- Modify `docs/users/features/channels/dingtalk.md`: document the new setting and its scope.
+
+### Task 1: Preserve session identity through response delivery
+
+**Files:**
+
+- Modify: `packages/channels/base/src/ChannelBase.ts:1216-1222, 3470-3570`
+- Modify: `packages/channels/base/src/ChannelBase.test.ts:35-90, 8050-8080`
+
+**Interfaces:**
+
+- Consumes: `sendMessage(chatId: string, text: string): Promise` implemented by every adapter.
+- Produces: `protected sendResponseMessage(chatId: string, text: string, sessionId: string): Promise` for adapter-specific agent-response delivery.
+
+- [ ] **Step 1: Write the failing block-streaming routing test**
+
+Add a test-only subclass and test beside the existing block-streaming tests:
+
+```ts
+class ResponseTrackingChannel extends TestChannel {
+ responseDeliveries: Array<{
+ chatId: string;
+ text: string;
+ sessionId: string;
+ }> = [];
+
+ protected override async sendResponseMessage(
+ chatId: string,
+ text: string,
+ sessionId: string,
+ ): Promise {
+ this.responseDeliveries.push({ chatId, text, sessionId });
+ await super.sendResponseMessage(chatId, text, sessionId);
+ }
+}
+
+it('passes the prompt session to block-streamed response delivery', async () => {
+ (bridge.prompt as ReturnType).mockImplementation(
+ (sid: string) => {
+ (bridge as unknown as EventEmitter).emit('textChunk', sid, 'reply');
+ return Promise.resolve('reply');
+ },
+ );
+ const ch = new ResponseTrackingChannel(
+ 'test-chan',
+ defaultConfig({
+ blockStreaming: 'on',
+ blockStreamingChunk: { minChars: 1, maxChars: 100 },
+ blockStreamingCoalesce: { idleMs: 0 },
+ }),
+ bridge,
+ );
+
+ await ch.handleInbound(envelope());
+
+ expect(ch.responseDeliveries).toEqual([
+ { chatId: 'chat1', text: 'reply', sessionId: 's-1' },
+ ]);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "passes the prompt session"`
+
+Expected: FAIL because `sendResponseMessage` does not exist and block streaming calls `sendMessage` directly.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Replace the default completion body and the block streamer callback with:
+
+```ts
+protected async sendResponseMessage(
+ chatId: string,
+ text: string,
+ _sessionId: string,
+): Promise {
+ await this.sendMessage(chatId, text);
+}
+
+protected async onResponseComplete(
+ chatId: string,
+ fullText: string,
+ sessionId: string,
+): Promise {
+ await this.sendResponseMessage(chatId, fullText, sessionId);
+}
+
+const streamer = useBlockStreaming
+ ? new BlockStreamer({
+ minChars: this.config.blockStreamingChunk?.minChars ?? 400,
+ maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
+ idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
+ send: (text) =>
+ this.sendResponseMessage(envelope.chatId, text, sessionId),
+ })
+ : null;
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "passes the prompt session"`
+
+Expected: PASS with one delivery tagged `s-1`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts
+git commit -m "feat(channels): preserve session for response delivery"
+```
+
+### Task 2: Send a real DingTalk mention for the correlated prompt
+
+**Files:**
+
+- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts:106-112, 304-340, 953-1020`
+- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts:86-104, 1140-1164`
+
+**Interfaces:**
+
+- Consumes: `sendResponseMessage(chatId, text, sessionId)` from Task 1 and inbound `msgId`, `senderStaffId`, and `conversationType` from DingTalk.
+- Produces: Markdown session-webhook payloads containing `at: { atUserIds: [staffId] }` only when `atSender` is enabled for the correlated group prompt.
+
+- [ ] **Step 1: Write failing adapter payload tests**
+
+Add a `DingtalkChannel reply mentions` suite before proactive-send tests. Use a mocked session webhook and `fetch`; seed the private maps through the existing test cast pattern.
+
+```ts
+it('mentions the originating group member when atSender is enabled', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ expect(
+ JSON.parse(String((fetchSpy.mock.calls[0]![1] as RequestInit).body)),
+ ).toMatchObject({
+ msgtype: 'markdown',
+ markdown: { text: 'hello' },
+ at: { atUserIds: ['staff-1'] },
+ });
+});
+```
+
+Add three equivalent assertions: default config has no `at`, an enabled prompt without a stored staff ID has no `at`, and a response longer than 3800 characters produces two payloads where only the first has `at`.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "reply mentions"`
+
+Expected: FAIL because `createChannel` has no `atSender` test override and the adapter has no response/session correlation or `at` payload.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `DingtalkChannel`, add these fields and helpers:
+
+```ts
+private readonly atSender: boolean;
+private mentionTargets = new Map();
+private sessionMentionTargets = new Map();
+
+private async sendReply(
+ chatId: string,
+ text: string,
+ atUserId?: string,
+): Promise {
+ const webhook = this.webhooks.get(chatId);
+ if (!webhook) return;
+ const chunks = normalizeDingTalkMarkdown(text);
+ const title = extractTitle(text);
+ for (let i = 0; i < chunks.length; i++) {
+ const body = {
+ msgtype: 'markdown',
+ markdown: { title: i === 0 ? title : `${title} (cont.)`, text: chunks[i]! },
+ ...(i === 0 && atUserId ? { at: { atUserIds: [atUserId] } } : {}),
+ };
+ await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ }
+}
+```
+
+Set `this.atSender` in the constructor with `(config as Record)['atSender'] === true`. Before `processMessage()` in `onMessage`, record only non-empty `msgId`, group, and `senderStaffId` targets. Extend the existing dedup timer to delete the same message ID from `mentionTargets` when it expires. Override `onPromptStart` to move a stored target to `sessionMentionTargets`; override `onPromptEnd` to clear that session entry; and override `sendResponseMessage` to call `sendReply` with the stored ID. Keep public `sendMessage` delegating to `sendReply(chatId, text)` so commands, fallbacks, and proactive paths do not acquire a mention.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "reply mentions"`
+
+Expected: PASS; the first enabled response payload contains exactly `['staff-1']` and all other asserted payloads omit `at`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts
+git commit -m "feat(dingtalk): optionally mention response sender"
+```
+
+### Task 3: Document configuration and validate the complete local change
+
+**Files:**
+
+- Modify: `docs/users/features/channels/dingtalk.md:28-43, 78-88, 102-106`
+
+**Interfaces:**
+
+- Consumes: the `atSender` behavior from Task 2.
+- Produces: a copyable configuration example and an accurate statement of mention scope.
+
+- [ ] **Step 1: Add documentation**
+
+Add this configuration line after `groupPolicy` in the existing JSON example:
+
+```json
+"atSender": true,
+```
+
+Add this group-chat paragraph after the current mention-triggering explanation:
+
+```md
+Set `"atSender": true` to have the bot @mention the member whose group message triggered its response. It is off by default; it only applies to agent replies with a DingTalk staff ID, and only the first message of a long reply contains the mention.
+```
+
+- [ ] **Step 2: Verify documentation formatting**
+
+Run: `npx prettier --check docs/users/features/channels/dingtalk.md`
+
+Expected: PASS.
+
+- [ ] **Step 3: Run focused regression tests**
+
+Run:
+
+```bash
+cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
+cd ../dingtalk && npx vitest run src/DingtalkAdapter.test.ts
+```
+
+Expected: both test files PASS with no failures.
+
+- [ ] **Step 4: Run build and typecheck from the worktree root**
+
+Run: `npm run build && npm run typecheck`
+
+Expected: both commands exit 0.
+
+- [ ] **Step 5: Perform local DingTalk verification before any push**
+
+Run:
+
+```bash
+npm run bundle
+node dist/cli.js channel start my-dingtalk
+```
+
+With `"atSender": true` in the local channel configuration, @ the bot from an internal DingTalk group account and send a short prompt. Expected: the first response visibly @mentions and notifies that account. Repeat with `"atSender": false`; expected: an identical reply without a mention.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add docs/users/features/channels/dingtalk.md
+git commit -m "docs(dingtalk): explain response mentions"
+git status --short
+```
diff --git a/docs/superpowers/specs/2026-07-10-dingtalk-at-sender-design.md b/docs/superpowers/specs/2026-07-10-dingtalk-at-sender-design.md
new file mode 100644
index 00000000000..5a0df72b932
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-dingtalk-at-sender-design.md
@@ -0,0 +1,55 @@
+# DingTalk At-Sender Replies Design
+
+## Goal
+
+Allow a DingTalk channel to optionally mention the person whose group message
+triggered an agent response.
+
+## Configuration
+
+Each DingTalk channel accepts an optional boolean `atSender` setting. It
+defaults to `false`.
+
+```json
+{
+ "channels": {
+ "my-dingtalk": {
+ "type": "dingtalk",
+ "atSender": true
+ }
+ }
+}
+```
+
+## Behaviour
+
+When `atSender` is enabled, a normal agent reply to a group message includes
+the originating message's `senderStaffId` in DingTalk's Markdown `atUserIds`.
+Only the first outbound chunk contains this field, so a long response does not
+notify the same person repeatedly.
+
+The adapter sends an ordinary reply without an `at` field when the setting is
+disabled, the message is a DM, or DingTalk did not supply a staff ID. Scheduled
+and proactive sends, local command responses, and adapter error fallbacks are
+also unchanged because they do not belong to a specific inbound agent prompt.
+
+## Correlation
+
+The adapter records each inbound group message's staff ID by its DingTalk
+message ID. When `ChannelBase` starts the corresponding prompt, its existing
+`onPromptStart(chatId, sessionId, messageId)` hook binds that ID to the session.
+`ChannelBase` passes the session ID through a protected response-delivery hook
+for both complete and block-streamed output; the adapter retrieves the bound
+staff ID there and passes it to its own reply sender. Prompt completion clears
+the session binding.
+
+This correlation avoids deriving the recipient from the latest message in a
+chat, which would mention the wrong person when prompts queue or overlap.
+
+## Validation
+
+Unit tests assert that enabled group replies include exactly one `atUserIds`
+entry, disabled replies and missing staff IDs omit it, and multi-chunk replies
+mention only in the first chunk. The DingTalk adapter test suite, build, and
+typecheck validate the local implementation. A manual DingTalk group test
+checks that the first reply produces a real mention and notification.
diff --git a/docs/users/features/channels/dingtalk.md b/docs/users/features/channels/dingtalk.md
index 445dc387628..d7e9c59d7f9 100644
--- a/docs/users/features/channels/dingtalk.md
+++ b/docs/users/features/channels/dingtalk.md
@@ -36,6 +36,7 @@ Add the channel to `~/.qwen/settings.json`:
"cwd": "/path/to/your/project",
"instructions": "You are a concise coding assistant responding via DingTalk.",
"groupPolicy": "open",
+ "atSender": true,
"groups": {
"*": { "requireMention": true }
}
@@ -90,6 +91,8 @@ DingTalk bots work in both DM and group conversations. To enable group support:
By default, the bot requires an @mention in group chats (`requireMention: true`). Set `"requireMention": false` for a specific group to make it respond to all messages. See [Group Chats](./overview#group-chats) for full details.
+Set `"atSender": true` to have the bot @mention the member whose group message triggered its response. It is off by default and only applies to agent replies with a DingTalk staff ID. Mentioned replies use plain text so the @ is visible; replies without a mention use Markdown formatting.
+
### Finding a Group's Conversation ID
DingTalk uses `conversationId` to identify groups. You can find it in the channel service logs when someone sends a message in the group — look for the `conversationId` field in the log output.
diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts
index 27ac5119adb..df95bef8ec0 100644
--- a/packages/channels/base/src/ChannelBase.test.ts
+++ b/packages/channels/base/src/ChannelBase.test.ts
@@ -186,6 +186,23 @@ class TestChannel extends ChannelBase {
}
}
+class ResponseTrackingChannel extends TestChannel {
+ responseDeliveries: Array<{
+ chatId: string;
+ text: string;
+ sessionId: string;
+ }> = [];
+
+ protected override async sendResponseMessage(
+ chatId: string,
+ text: string,
+ sessionId: string,
+ ): Promise {
+ this.responseDeliveries.push({ chatId, text, sessionId });
+ await super.sendResponseMessage(chatId, text, sessionId);
+ }
+}
+
class UnsafeProcessChannel extends TestChannel {
processWithoutPreflight(envelope: Envelope): Promise {
return this.processInbound(envelope);
@@ -8106,6 +8123,30 @@ describe('ChannelBase', () => {
});
describe('block streaming', () => {
+ it('passes the prompt session to block-streamed response delivery', async () => {
+ (bridge.prompt as ReturnType).mockImplementation(
+ (sid: string) => {
+ (bridge as unknown as EventEmitter).emit('textChunk', sid, 'reply');
+ return Promise.resolve('reply');
+ },
+ );
+ const ch = new ResponseTrackingChannel(
+ 'test-chan',
+ defaultConfig({
+ blockStreaming: 'on',
+ blockStreamingChunk: { minChars: 1, maxChars: 100 },
+ blockStreamingCoalesce: { idleMs: 0 },
+ }),
+ bridge,
+ );
+
+ await ch.handleInbound(envelope());
+
+ expect(ch.responseDeliveries).toEqual([
+ { chatId: 'chat1', text: 'reply', sessionId: 's-1' },
+ ]);
+ });
+
it('uses block streamer when blockStreaming=on', async () => {
// The streamer sends blocks; onResponseComplete is NOT called
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts
index 80ba87d8c39..8334214cfe0 100644
--- a/packages/channels/base/src/ChannelBase.ts
+++ b/packages/channels/base/src/ChannelBase.ts
@@ -1505,17 +1505,25 @@ export abstract class ChannelBase {
*/
protected onResponseBoundary(_chatId: string, _sessionId: string): void {}
+ protected async sendResponseMessage(
+ chatId: string,
+ text: string,
+ _sessionId: string,
+ ): Promise {
+ await this.sendMessage(chatId, text);
+ }
+
/**
* Called when the agent's full response is ready.
* Override to customize delivery (e.g., finalize an AI card).
- * Default: calls sendMessage() with the full response text.
+ * Default: sends the full response text.
*/
protected async onResponseComplete(
chatId: string,
fullText: string,
- _sessionId: string,
+ sessionId: string,
): Promise {
- await this.sendMessage(chatId, fullText);
+ await this.sendResponseMessage(chatId, fullText, sessionId);
}
/**
@@ -3807,7 +3815,8 @@ export abstract class ChannelBase {
minChars: this.config.blockStreamingChunk?.minChars ?? 400,
maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
- send: (text) => this.sendMessage(envelope.chatId, text),
+ send: (text) =>
+ this.sendResponseMessage(envelope.chatId, text, sessionId),
})
: null;
promptState.stopStreaming = () => streamer?.stop();
diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts
index 4328fc0a5ac..22ee1522836 100644
--- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts
+++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
+import { EventEmitter } from 'node:events';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs';
import type {
ChannelTaskLifecycleEvent,
+ Envelope,
SessionTarget,
} from '@qwen-code/channel-base';
@@ -98,6 +103,16 @@ vi.mock('@qwen-code/channel-base', async () => {
}
).logDebugPayload.call(this, platform, payload);
}
+ protected onPromptBufferDropped(
+ _chatId: string,
+ _sessionId: string,
+ _messageIds: string[],
+ ): void {}
+ protected onPromptBufferDrained(
+ _chatId: string,
+ _sessionId: string,
+ _messageIds: string[],
+ ): void {}
constructor(
name: string,
@@ -258,6 +273,33 @@ function getPromptHook(
return fn.bind(channel);
}
+function getResponseHook(
+ channel: DingtalkChannelInstance,
+): (chatId: string, text: string, sessionId: string) => Promise {
+ const fn = (channel as unknown as Record)[
+ 'sendResponseMessage'
+ ] as (chatId: string, text: string, sessionId: string) => Promise;
+ return fn.bind(channel);
+}
+
+function getPromptBufferDropHook(
+ channel: DingtalkChannelInstance,
+): (chatId: string, sessionId: string, messageIds: string[]) => void {
+ const fn = (channel as unknown as Record)[
+ 'onPromptBufferDropped'
+ ] as (chatId: string, sessionId: string, messageIds: string[]) => void;
+ return fn.bind(channel);
+}
+
+function getPromptBufferDrainHook(
+ channel: DingtalkChannelInstance,
+): (chatId: string, sessionId: string, messageIds: string[]) => void {
+ const fn = (channel as unknown as Record)[
+ 'onPromptBufferDrained'
+ ] as (chatId: string, sessionId: string, messageIds: string[]) => void;
+ return fn.bind(channel);
+}
+
function getLifecycleHook(
channel: DingtalkChannelInstance,
): (event: ChannelTaskLifecycleEvent) => void {
@@ -277,6 +319,23 @@ function seedSeenMessage(
).inboundMessageIds.add(messageId);
}
+function seedWebhook(channel: DingtalkChannelInstance, chatId: string): void {
+ (channel as unknown as { webhooks: Map }).webhooks.set(
+ chatId,
+ 'https://oapi.dingtalk.com/robot/send?access_token=token',
+ );
+}
+
+function seedMentionTarget(
+ channel: DingtalkChannelInstance,
+ messageId: string,
+ staffId: string,
+): void {
+ (
+ channel as unknown as { mentionTargets: Map }
+ ).mentionTargets.set(messageId, staffId);
+}
+
function deferredPromise() {
let resolve!: (value: T | PromiseLike) => void;
let reject!: (reason?: unknown) => void;
@@ -290,6 +349,7 @@ function deferredPromise() {
describe('DingtalkChannel prompt reactions', () => {
afterEach(() => {
vi.restoreAllMocks();
+ vi.unstubAllEnvs();
});
it('maps lifecycle start and terminal events to the eye reaction', () => {
@@ -319,6 +379,7 @@ describe('DingtalkChannel prompt reactions', () => {
} satisfies LifecycleBase;
seedSeenMessage(channel, 'message-1');
+ seedMentionTarget(channel, 'message-1', 'staff-1');
const lifecycle = getLifecycleHook(channel);
lifecycle({ ...event, type: 'started' });
lifecycle({ ...event, type: 'started' });
@@ -329,6 +390,11 @@ describe('DingtalkChannel prompt reactions', () => {
expect(attachReaction).toHaveBeenCalledWith('message-1', 'cid-123');
expect(recallReaction).toHaveBeenCalledOnce();
expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123');
+ expect(
+ (
+ channel as unknown as { mentionTargets: Map }
+ ).mentionTargets.has('message-1'),
+ ).toBe(false);
});
it('recalls again when a late lifecycle attach resolves after terminal cleanup', async () => {
@@ -1297,6 +1363,533 @@ describe('DingtalkChannel sender attribution', () => {
});
});
+describe('DingtalkChannel reply mentions', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it('retains queued mention after dedup cleanup until onPromptStart', async () => {
+ vi.useFakeTimers();
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ (
+ channel as unknown as { seenMessages: Map }
+ ).seenMessages.set('m1', Date.now() - 5 * 60 * 1000 - 1);
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ try {
+ await channel.connect();
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const body = JSON.parse(
+ String((fetchSpy.mock.calls[0]![1] as RequestInit).body),
+ );
+ expect(body).toMatchObject({
+ msgtype: 'text',
+ text: { content: '@staff-1\n\nhello' },
+ at: { atUserIds: ['staff-1'] },
+ });
+ } finally {
+ channel.disconnect();
+ vi.useRealTimers();
+ }
+ });
+
+ it('removes mention targets for dropped queued prompts', () => {
+ const channel = createChannel({ atSender: true });
+ seedMentionTarget(channel, 'm1', 'staff-1');
+
+ getPromptBufferDropHook(channel)('cid123', 'session-1', ['m1']);
+
+ expect(
+ (
+ channel as unknown as { mentionTargets: Map }
+ ).mentionTargets.has('m1'),
+ ).toBe(false);
+ });
+
+ it('keeps only the final mention target for a coalesced queued prompt', () => {
+ const channel = createChannel({ atSender: true });
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ seedMentionTarget(channel, 'm2', 'staff-2');
+
+ getPromptBufferDrainHook(channel)('cid123', 'session-1', ['m1', 'm2']);
+
+ const mentionTargets = (
+ channel as unknown as { mentionTargets: Map }
+ ).mentionTargets;
+ expect(mentionTargets.has('m1')).toBe(false);
+ expect(mentionTargets.get('m2')).toBe('staff-2');
+ });
+
+ it('mentions the originating group member when atSender is enabled', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ expect(
+ JSON.parse(String((fetchSpy.mock.calls[0]![1] as RequestInit).body)),
+ ).toMatchObject({
+ msgtype: 'text',
+ text: { content: '@staff-1\n\nhello' },
+ at: { atUserIds: ['staff-1'] },
+ });
+ });
+
+ it('logs the redacted mention delivery result when diagnostics are enabled', async () => {
+ vi.stubEnv('QWEN_CHANNEL_DEBUG_MENTIONS', '1');
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ errcode: 0 }), { status: 200 }),
+ );
+ const writeSpy = vi
+ .spyOn(process.stderr, 'write')
+ .mockImplementation(() => true);
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ expect(writeSpy).toHaveBeenCalledWith(
+ '[DingTalk:test-dingtalk] mention delivery status=200 code=0\n',
+ );
+ });
+
+ it('does not mention the sender by default', async () => {
+ const channel = createChannel();
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ const body = JSON.parse(
+ String((fetchSpy.mock.calls[0]![1] as RequestInit).body),
+ );
+ expect(body).toMatchObject({
+ msgtype: 'markdown',
+ markdown: { text: 'hello' },
+ });
+ expect(body).not.toHaveProperty('at');
+ });
+
+ it('does not mention when the correlated prompt has no stored staff ID', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'hello', 'session-1');
+
+ const body = JSON.parse(
+ String((fetchSpy.mock.calls[0]![1] as RequestInit).body),
+ );
+ expect(body).toMatchObject({
+ msgtype: 'markdown',
+ markdown: { text: 'hello' },
+ });
+ expect(body).not.toHaveProperty('at');
+ });
+
+ it('reserves the mention prefix within the first text chunk limit', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ const text = 'a'.repeat(3800);
+ await getResponseHook(channel)('cid123', text, 'session-1');
+
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const bodies = fetchSpy.mock.calls.map(([, init]) =>
+ JSON.parse(String((init as RequestInit).body)),
+ );
+ expect(bodies[0]).toMatchObject({
+ msgtype: 'text',
+ at: { atUserIds: ['staff-1'] },
+ });
+ expect(bodies[1]).toMatchObject({ msgtype: 'text' });
+ expect(bodies[1]).not.toHaveProperty('at');
+ expect(bodies.map((body) => body.text.content.length)).toEqual([3800, 10]);
+ expect(
+ bodies
+ .map((body, index) =>
+ index === 0
+ ? body.text.content.slice('@staff-1\n\n'.length)
+ : body.text.content,
+ )
+ .join(''),
+ ).toBe(text);
+ });
+
+ it('preserves code fences across mentioned text chunks', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+ const text = `\`\`\`\n${'a'.repeat(3800)}\n\`\`\``;
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', text, 'session-1');
+
+ const contents = fetchSpy.mock.calls.map(([, init], index) => {
+ const body = JSON.parse(String((init as RequestInit).body));
+ return index === 0
+ ? body.text.content.slice('@staff-1\n\n'.length)
+ : body.text.content;
+ });
+ expect(contents.join('')).toBe(text);
+ expect(
+ fetchSpy.mock.calls.every(([, init]) => {
+ const body = JSON.parse(String((init as RequestInit).body));
+ return body.text.content.length <= 3800;
+ }),
+ ).toBe(true);
+ });
+
+ it('mentions only the first block-streamed response', async () => {
+ const channel = createChannel({ atSender: true });
+ seedWebhook(channel, 'cid123');
+ seedMentionTarget(channel, 'm1', 'staff-1');
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
+ await getResponseHook(channel)('cid123', 'first block', 'session-1');
+ await getResponseHook(channel)('cid123', 'second block', 'session-1');
+
+ const bodies = fetchSpy.mock.calls.map(([, init]) =>
+ JSON.parse(String((init as RequestInit).body)),
+ );
+ expect(bodies[0]).toMatchObject({ at: { atUserIds: ['staff-1'] } });
+ expect(bodies[0].msgtype).toBe('text');
+ expect(bodies[1]).toMatchObject({ msgtype: 'text' });
+ expect(bodies[1]).not.toHaveProperty('at');
+ });
+});
+
+describe('DingtalkChannel mention target lifecycle', () => {
+ it('does not retain a preflight-rejected group candidate', async () => {
+ vi.doUnmock('@qwen-code/channel-base');
+ vi.resetModules();
+ const { DingtalkChannel: RealDingtalkChannel } = await import(
+ './DingtalkAdapter.js'
+ );
+ const bridge = Object.assign(new EventEmitter(), {
+ availableCommands: [],
+ newSession: vi.fn().mockResolvedValue('session-1'),
+ loadSession: vi.fn(),
+ prompt: vi.fn().mockResolvedValue('agent response'),
+ cancelSession: vi.fn().mockResolvedValue(undefined),
+ }) as never;
+ const createRealChannel = (groups: Record) =>
+ new RealDingtalkChannel(
+ 'real-dingtalk',
+ {
+ type: 'dingtalk',
+ token: '',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ senderPolicy: 'open',
+ allowedUsers: [],
+ sessionScope: 'user',
+ cwd: '/tmp',
+ groupPolicy: 'open',
+ dmPolicy: 'open',
+ atSender: true,
+ groups,
+ },
+ bridge,
+ {
+ registerBridgeEvents: false,
+ groupHistoryPath: join(
+ mkdtempSync(join(tmpdir(), 'dingtalk-mention-lifecycle-')),
+ 'history.jsonl',
+ ),
+ },
+ );
+ const sendInbound = (
+ channel: InstanceType,
+ msgId: string,
+ text: string,
+ isInAtList: boolean,
+ ) => {
+ (
+ channel as unknown as {
+ onMessage(downstream: DWClientDownStream): void;
+ }
+ ).onMessage({
+ data: JSON.stringify({
+ msgId,
+ conversationType: '2',
+ conversationId: 'cid-123',
+ sessionWebhook:
+ 'https://oapi.dingtalk.com/robot/send?access_token=token',
+ senderStaffId: 'staff-123',
+ senderId: 'sender-123',
+ senderNick: 'Alice',
+ isInAtList,
+ text: { content: text },
+ }),
+ headers: { messageId: msgId },
+ } as unknown as DWClientDownStream);
+ };
+ const targetMap = (channel: InstanceType) =>
+ (channel as unknown as { mentionTargets: Map })
+ .mentionTargets;
+ const rejected = createRealChannel({ '*': { requireMention: true } });
+ sendInbound(rejected, 'rejected-1', 'not for the bot', false);
+
+ await vi.waitFor(() => {
+ expect(targetMap(rejected).has('rejected-1')).toBe(false);
+ });
+ });
+
+ it('does not retain a local-command candidate', async () => {
+ vi.doUnmock('@qwen-code/channel-base');
+ vi.resetModules();
+ const { DingtalkChannel: RealDingtalkChannel } = await import(
+ './DingtalkAdapter.js'
+ );
+ const bridge = Object.assign(new EventEmitter(), {
+ availableCommands: [],
+ newSession: vi.fn().mockResolvedValue('session-1'),
+ loadSession: vi.fn(),
+ prompt: vi.fn().mockResolvedValue('agent response'),
+ cancelSession: vi.fn().mockResolvedValue(undefined),
+ }) as never;
+ const channel = new RealDingtalkChannel(
+ 'real-dingtalk',
+ {
+ type: 'dingtalk',
+ token: '',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ senderPolicy: 'open',
+ allowedUsers: [],
+ sessionScope: 'user',
+ cwd: '/tmp',
+ groupPolicy: 'open',
+ dmPolicy: 'open',
+ atSender: true,
+ groups: { '*': { requireMention: false } },
+ },
+ bridge,
+ { registerBridgeEvents: false },
+ );
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+ (
+ channel as unknown as {
+ onMessage(downstream: DWClientDownStream): void;
+ }
+ ).onMessage({
+ data: JSON.stringify({
+ msgId: 'command-1',
+ conversationType: '2',
+ conversationId: 'cid-123',
+ sessionWebhook:
+ 'https://oapi.dingtalk.com/robot/send?access_token=token',
+ senderStaffId: 'staff-123',
+ senderId: 'sender-123',
+ senderNick: 'Alice',
+ isInAtList: true,
+ text: { content: '/help' },
+ }),
+ headers: { messageId: 'command-1' },
+ } as unknown as DWClientDownStream);
+
+ await vi.waitFor(() => {
+ expect(
+ (
+ channel as unknown as { mentionTargets: Map }
+ ).mentionTargets.has('command-1'),
+ ).toBe(false);
+ });
+ expect(bridge.prompt).not.toHaveBeenCalled();
+ fetchSpy.mockRestore();
+ });
+
+ it('clears the final buffered command target after synthetic collect re-entry', async () => {
+ vi.doUnmock('@qwen-code/channel-base');
+ vi.resetModules();
+ const { DingtalkChannel: RealDingtalkChannel } = await import(
+ './DingtalkAdapter.js'
+ );
+ const firstPrompt = deferredPromise();
+ const bridge = Object.assign(new EventEmitter(), {
+ availableCommands: [],
+ newSession: vi.fn().mockResolvedValue('session-1'),
+ loadSession: vi.fn(),
+ prompt: vi.fn().mockReturnValueOnce(firstPrompt.promise),
+ cancelSession: vi.fn().mockResolvedValue(undefined),
+ }) as never;
+ const channel = new RealDingtalkChannel(
+ 'real-dingtalk',
+ {
+ type: 'dingtalk',
+ token: '',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ senderPolicy: 'open',
+ allowedUsers: [],
+ sessionScope: 'user',
+ cwd: '/tmp',
+ groupPolicy: 'open',
+ dmPolicy: 'open',
+ dispatchMode: 'collect',
+ atSender: true,
+ groups: { '*': { requireMention: false } },
+ },
+ bridge,
+ { registerBridgeEvents: false },
+ );
+ const finalCommand: Envelope = {
+ chatId: 'cid-123',
+ senderId: 'sender-123',
+ senderName: 'Alice',
+ messageId: 'command-1',
+ text: '/help',
+ isGroup: true,
+ isMentioned: true,
+ };
+ const initial = channel.handleInbound({
+ ...finalCommand,
+ messageId: 'active-1',
+ text: 'first request',
+ });
+ await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledOnce());
+
+ const internals = channel as unknown as {
+ collectBuffers: Map>;
+ mentionTargets: Map;
+ onPromptBuffered(
+ chatId: string,
+ sessionId: string,
+ messageId?: string,
+ ): void;
+ };
+ internals.mentionTargets.set('command-1', 'staff-123');
+ internals.collectBuffers.set('session-1', [
+ { text: '/help', envelope: finalCommand },
+ ]);
+ internals.onPromptBuffered('cid-123', 'session-1', 'command-1');
+
+ firstPrompt.resolve('first response');
+ await initial;
+
+ await vi.waitFor(() => {
+ expect(internals.mentionTargets.has('command-1')).toBe(false);
+ });
+ expect(bridge.prompt).toHaveBeenCalledOnce();
+ });
+
+ it('clears buffered mention targets for a dead session only', async () => {
+ vi.doUnmock('@qwen-code/channel-base');
+ vi.resetModules();
+ const { DingtalkChannel: RealDingtalkChannel } = await import(
+ './DingtalkAdapter.js'
+ );
+ const bridge = Object.assign(new EventEmitter(), {
+ availableCommands: [],
+ newSession: vi.fn(),
+ loadSession: vi.fn(),
+ prompt: vi.fn(),
+ cancelSession: vi.fn(),
+ }) as never;
+ const channel = new RealDingtalkChannel(
+ 'real-dingtalk',
+ {
+ type: 'dingtalk',
+ token: '',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ senderPolicy: 'open',
+ allowedUsers: [],
+ sessionScope: 'user',
+ cwd: '/tmp',
+ groupPolicy: 'open',
+ dmPolicy: 'open',
+ atSender: true,
+ groups: {},
+ },
+ bridge,
+ { registerBridgeEvents: false },
+ );
+ const internals = channel as unknown as {
+ mentionTargets: Map;
+ sessionMentionTargets: Map;
+ textReplySessions: Set;
+ bufferedMentionTargets: Set;
+ bufferedMentionTargetsBySession: Map>;
+ onPromptBuffered(
+ chatId: string,
+ sessionId: string,
+ messageId?: string,
+ ): void;
+ };
+ internals.mentionTargets.set('buffered-1', 'staff-buffered');
+ internals.mentionTargets.set('queued-1', 'staff-queued');
+ internals.mentionTargets.set('other-1', 'staff-other');
+ internals.onPromptBuffered('cid-123', 'session-1', 'buffered-1');
+ internals.onPromptBuffered('cid-123', 'session-1', 'queued-1');
+ internals.onPromptBuffered('cid-123', 'session-2', 'other-1');
+ internals.sessionMentionTargets.set('session-1', 'staff-active');
+ internals.sessionMentionTargets.set('session-2', 'staff-other-active');
+ internals.textReplySessions.add('session-1');
+ internals.textReplySessions.add('session-2');
+
+ channel.onSessionDied('session-1');
+
+ expect(internals.mentionTargets.has('buffered-1')).toBe(false);
+ expect(internals.mentionTargets.has('queued-1')).toBe(false);
+ expect(internals.bufferedMentionTargets.has('buffered-1')).toBe(false);
+ expect(internals.bufferedMentionTargets.has('queued-1')).toBe(false);
+ expect(internals.bufferedMentionTargetsBySession.has('session-1')).toBe(
+ false,
+ );
+ expect(internals.sessionMentionTargets.has('session-1')).toBe(false);
+ expect(internals.textReplySessions.has('session-1')).toBe(false);
+ expect(internals.mentionTargets.get('other-1')).toBe('staff-other');
+ expect(internals.bufferedMentionTargets.has('other-1')).toBe(true);
+ expect(internals.bufferedMentionTargetsBySession.get('session-2')).toEqual(
+ new Set(['other-1']),
+ );
+ expect(internals.sessionMentionTargets.get('session-2')).toBe(
+ 'staff-other-active',
+ );
+ expect(internals.textReplySessions.has('session-2')).toBe(true);
+ });
+});
+
describe('DingtalkChannel proactive send', () => {
afterEach(() => {
vi.restoreAllMocks();
diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts
index 5b3701a0b62..7f6588445bf 100644
--- a/packages/channels/dingtalk/src/DingtalkAdapter.ts
+++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts
@@ -91,6 +91,12 @@ const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
const GROUP_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
+const TEXT_MESSAGE_LIMIT = 3800;
+const mentionTarget = Symbol('mentionTarget');
+
+type MentionTargetEnvelope = Envelope & {
+ [mentionTarget]?: string;
+};
interface DingTalkTokenResponse {
errcode?: number;
@@ -99,6 +105,20 @@ interface DingTalkTokenResponse {
expires_in?: number;
}
+function splitTextChunks(text: string, firstChunkLimit: number): string[] {
+ if (!text) return [text];
+
+ const chunks: string[] = [];
+ let offset = 0;
+ let chunkLimit = firstChunkLimit;
+ while (offset < text.length) {
+ chunks.push(text.slice(offset, offset + chunkLimit));
+ offset += chunkLimit;
+ chunkLimit = TEXT_MESSAGE_LIMIT;
+ }
+ return chunks;
+}
+
type DingTalkClientInternals = DWClient & {
debug: boolean;
onDownStream(data: unknown): void;
@@ -113,8 +133,14 @@ type DingtalkChannelConfig = ChannelConfig & {
export class DingtalkChannel extends ChannelBase {
private client: DWClient;
+ private readonly atSender: boolean;
private connectionManager?: DingtalkConnectionManager;
private seenMessages: Map = new Map();
+ private mentionTargets = new Map();
+ private sessionMentionTargets = new Map();
+ private textReplySessions = new Set();
+ private bufferedMentionTargets = new Set();
+ private bufferedMentionTargetsBySession = new Map>();
private dedupTimer?: ReturnType;
/** Map conversationId → latest sessionWebhook URL for sending replies. */
private webhooks: Map = new Map();
@@ -144,6 +170,9 @@ export class DingtalkChannel extends ChannelBase {
) {
super(name, config, bridge, options);
+ this.atSender =
+ (config as unknown as Record)['atSender'] === true;
+
if (!config.clientId || !config.clientSecret) {
throw new Error(
`Channel "${name}" requires clientId and clientSecret for DingTalk.`,
@@ -352,7 +381,7 @@ export class DingtalkChannel extends ChannelBase {
return isGroup && !conversationId;
}
- async sendMessage(chatId: string, text: string): Promise {
+ private async sendReply(chatId: string, text: string): Promise {
// chatId is a conversationId — resolve to the latest sessionWebhook
const webhook = this.webhooks.get(chatId);
if (!webhook) {
@@ -390,6 +419,65 @@ export class DingtalkChannel extends ChannelBase {
}
}
+ private async sendTextReply(
+ chatId: string,
+ text: string,
+ atUserId?: string,
+ ): Promise {
+ const webhook = this.webhooks.get(chatId);
+ if (!webhook) return;
+
+ const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
+ const chunks = splitTextChunks(
+ text,
+ TEXT_MESSAGE_LIMIT - mentionPrefix.length,
+ );
+ for (let i = 0; i < chunks.length; i++) {
+ const isMention = i === 0 && atUserId !== undefined;
+ const resp = await fetch(webhook, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ msgtype: 'text',
+ text: {
+ content: isMention ? `${mentionPrefix}${chunks[i]!}` : chunks[i]!,
+ },
+ ...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
+ }),
+ });
+
+ if (isMention && process.env['QWEN_CHANNEL_DEBUG_MENTIONS'] === '1') {
+ const payload = (await resp
+ .clone()
+ .json()
+ .catch(() => undefined)) as unknown;
+ const response =
+ payload && typeof payload === 'object'
+ ? (payload as Record)
+ : {};
+ const value = response['errcode'] ?? response['code'];
+ const code =
+ typeof value === 'number' || typeof value === 'string'
+ ? String(value)
+ : 'unknown';
+ process.stderr.write(
+ `[DingTalk:${this.name}] mention delivery status=${resp.status} code=${code}\n`,
+ );
+ }
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => '');
+ process.stderr.write(
+ `[DingTalk:${this.name}] sendTextReply failed: HTTP ${resp.status} ${detail}\n`,
+ );
+ }
+ }
+ }
+
+ async sendMessage(chatId: string, text: string): Promise {
+ await this.sendReply(chatId, text);
+ }
+
override supportsProactiveSend(): boolean {
return true;
}
@@ -681,6 +769,16 @@ export class DingtalkChannel extends ChannelBase {
/** Recall reactions left behind when a session dies without terminal lifecycle events. */
override onSessionDied(sessionId: string): void {
+ const bufferedTargets = this.bufferedMentionTargetsBySession.get(sessionId);
+ if (bufferedTargets) {
+ this.bufferedMentionTargetsBySession.delete(sessionId);
+ for (const messageId of bufferedTargets) {
+ this.bufferedMentionTargets.delete(messageId);
+ this.mentionTargets.delete(messageId);
+ }
+ }
+ this.sessionMentionTargets.delete(sessionId);
+ this.textReplySessions.delete(sessionId);
const keys = this.sessionReactionKeys.get(sessionId);
if (keys) {
this.sessionReactionKeys.delete(sessionId);
@@ -701,26 +799,131 @@ export class DingtalkChannel extends ChannelBase {
return;
}
if (isTerminalTaskLifecycleType(event.type)) {
+ if (event.messageId) this.mentionTargets.delete(event.messageId);
this.stopReaction(event.chatId, event.messageId, event.sessionId);
}
}
+ protected override onPromptBufferDropped(
+ _chatId: string,
+ sessionId: string,
+ messageIds: string[],
+ ): void {
+ for (const messageId of messageIds) {
+ this.bufferedMentionTargets.delete(messageId);
+ this.mentionTargets.delete(messageId);
+ this.untrackBufferedMentionTarget(sessionId, messageId);
+ }
+ }
+
+ protected override onPromptBufferDrained(
+ _chatId: string,
+ sessionId: string,
+ messageIds: string[],
+ ): void {
+ for (const messageId of messageIds) {
+ this.bufferedMentionTargets.delete(messageId);
+ this.untrackBufferedMentionTarget(sessionId, messageId);
+ }
+ for (const messageId of messageIds.slice(0, -1)) {
+ this.mentionTargets.delete(messageId);
+ }
+ }
+
+ protected override onPromptBuffered(
+ _chatId: string,
+ sessionId: string,
+ messageId?: string,
+ ): void {
+ if (messageId && this.mentionTargets.has(messageId)) {
+ this.bufferedMentionTargets.add(messageId);
+ let targets = this.bufferedMentionTargetsBySession.get(sessionId);
+ if (!targets) {
+ targets = new Set();
+ this.bufferedMentionTargetsBySession.set(sessionId, targets);
+ }
+ targets.add(messageId);
+ }
+ }
+
protected override onPromptStart(
chatId: string,
sessionId: string,
messageId?: string,
): void {
+ if (messageId) {
+ this.bufferedMentionTargets.delete(messageId);
+ this.untrackBufferedMentionTarget(sessionId, messageId);
+ const atUserId = this.mentionTargets.get(messageId);
+ this.mentionTargets.delete(messageId);
+ if (this.atSender && atUserId) {
+ this.sessionMentionTargets.set(sessionId, atUserId);
+ this.textReplySessions.add(sessionId);
+ }
+ }
this.startReaction(chatId, messageId, sessionId);
}
+ override async handleInbound(envelope: Envelope): Promise {
+ if (!(await this.preflightInbound(envelope))) return;
+
+ const messageId = envelope.messageId;
+ const atUserId = (envelope as MentionTargetEnvelope)[mentionTarget];
+ if (this.atSender && messageId && atUserId) {
+ this.mentionTargets.set(messageId, atUserId);
+ }
+
+ await this.processInbound(envelope);
+ }
+
+ protected override async processInbound(envelope: Envelope): Promise {
+ const messageId = envelope.messageId;
+ try {
+ await super.processInbound(envelope);
+ } finally {
+ if (messageId && !this.bufferedMentionTargets.has(messageId)) {
+ this.mentionTargets.delete(messageId);
+ }
+ }
+ }
+
+ private untrackBufferedMentionTarget(
+ sessionId: string,
+ messageId: string,
+ ): void {
+ const targets = this.bufferedMentionTargetsBySession.get(sessionId);
+ if (!targets) return;
+ targets.delete(messageId);
+ if (targets.size === 0)
+ this.bufferedMentionTargetsBySession.delete(sessionId);
+ }
+
protected override onPromptEnd(
chatId: string,
sessionId: string,
messageId?: string,
): void {
+ this.sessionMentionTargets.delete(sessionId);
+ this.textReplySessions.delete(sessionId);
this.stopReaction(chatId, messageId, sessionId);
}
+ protected override async sendResponseMessage(
+ chatId: string,
+ text: string,
+ sessionId: string,
+ ): Promise {
+ const atUserId = this.atSender
+ ? this.sessionMentionTargets.get(sessionId)
+ : undefined;
+ if (atUserId) this.sessionMentionTargets.delete(sessionId);
+ if (this.textReplySessions.has(sessionId)) {
+ await this.sendTextReply(chatId, text, atUserId);
+ return;
+ }
+ await this.sendReply(chatId, text);
+ }
+
/**
* Extract quoted/referenced message context from a reply.
* DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy).
@@ -1063,6 +1266,10 @@ export class DingtalkChannel extends ChannelBase {
// onPromptStart/onPromptEnd — no extra bookkeeping needed.
envelope.messageId = msgId;
+ if (this.atSender && isGroup && senderStaffId) {
+ (envelope as MentionTargetEnvelope)[mentionTarget] = senderStaffId;
+ }
+
const processMessage = async () => {
// Download media if present (first downloadCode only for images)
if (content.downloadCodes.length > 0 && content.mediaType) {
From ea60fc1c2ae956389c73d2aa8bd3091bf9caf2e9 Mon Sep 17 00:00:00 2001
From: ytahdn <1294726970@qq.com>
Date: Sat, 11 Jul 2026 08:13:49 +0800
Subject: [PATCH 10/11] feat(web-shell): add artifact right panel (#6591)
* feat(web-shell): add artifact right panel
* fix(web-shell): address artifact panel review feedback
* fix(web-shell): handle artifact panel review edge cases
* fix(web-shell): tighten scheduled task parsing
* fix(web-shell): address artifact panel review followups
* fix(web-shell): guard large file diff stats
* fix(web-shell): address review panel suggestions
* test(webui): stabilize heartbeat prompt cleanup test
* fix(web-shell): address artifact review refresh issues
* test(web-shell): stabilize ChatPane artifact hook mock
* fix(web-shell): clear stale session artifacts while loading
* fix(web-shell): preserve artifact tabs during refresh
* fix(web-shell): address artifact review followups
* fix(web-shell): respect workspace cwd for artifact outputs
* fix(web-shell): scope artifact panel actions to pane
* fix(web-shell): resolve split pane merge conflict
* fix(web-shell): clear stale artifact panel state
* fix(web-shell): preserve leading turn outputs
* fix(web-shell): tighten turn output selectors
* fix(web-shell): harden artifact preview sanitizer
* fix(web-shell): address artifact panel review regressions
* fix(web-shell): reconcile split pane artifact snapshots
* fix(web-shell): clear pane artifacts on session switch
* fix(web-shell): clear stale right panel snapshots
* fix(web-shell): repair scheduled task hint string
---------
Co-authored-by: ytahdn
Co-authored-by: qwen-code-dev-bot
---
package-lock.json | 14 +
.../src/daemon/ui/normalizer.ts | 28 +
.../sdk-typescript/src/daemon/ui/terminal.ts | 6 +
.../src/daemon/ui/transcript.ts | 1 +
.../sdk-typescript/src/daemon/ui/types.ts | 9 +
.../sdk-typescript/test/unit/daemonUi.test.ts | 65 +
packages/web-shell/client/App.module.css | 23 +
packages/web-shell/client/App.test.tsx | 183 +-
packages/web-shell/client/App.tsx | 595 ++++++
.../client/components/ChatPane.test.tsx | 48 +-
.../web-shell/client/components/ChatPane.tsx | 93 +-
.../client/components/MessageList.test.ts | 64 +
.../client/components/MessageList.tsx | 143 +-
.../web-shell/client/components/SplitView.tsx | 19 +
.../artifacts/ArtifactPanel.module.css | 807 ++++++++
.../components/artifacts/ArtifactPanel.tsx | 1814 +++++++++++++++++
.../client/components/artifacts/LineStats.tsx | 41 +
.../artifacts/TurnOutputs.module.css | 182 ++
.../components/artifacts/TurnOutputs.test.ts | 35 +
.../components/artifacts/TurnOutputs.tsx | 473 +++++
.../artifacts/artifactUtils.test.ts | 58 +
.../components/artifacts/artifactUtils.ts | 104 +
.../artifacts/turnOutputSelectors.test.ts | 603 ++++++
.../artifacts/turnOutputSelectors.ts | 506 +++++
.../client/hooks/useSessionArtifacts.test.tsx | 183 ++
.../client/hooks/useSessionArtifacts.ts | 127 ++
packages/web-shell/client/i18n.tsx | 62 +
packages/web-shell/client/index.ts | 4 +
packages/web-shell/client/index.tsx | 4 +
packages/web-shell/package.json | 1 +
.../session/DaemonSessionProvider.test.tsx | 24 +
.../daemon/session/DaemonSessionProvider.tsx | 19 +-
packages/webui/src/daemon/session/actions.ts | 23 +
packages/webui/src/daemon/session/types.ts | 4 +
34 files changed, 6349 insertions(+), 16 deletions(-)
create mode 100644 packages/web-shell/client/components/artifacts/ArtifactPanel.module.css
create mode 100644 packages/web-shell/client/components/artifacts/ArtifactPanel.tsx
create mode 100644 packages/web-shell/client/components/artifacts/LineStats.tsx
create mode 100644 packages/web-shell/client/components/artifacts/TurnOutputs.module.css
create mode 100644 packages/web-shell/client/components/artifacts/TurnOutputs.test.ts
create mode 100644 packages/web-shell/client/components/artifacts/TurnOutputs.tsx
create mode 100644 packages/web-shell/client/components/artifacts/artifactUtils.test.ts
create mode 100644 packages/web-shell/client/components/artifacts/artifactUtils.ts
create mode 100644 packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts
create mode 100644 packages/web-shell/client/components/artifacts/turnOutputSelectors.ts
create mode 100644 packages/web-shell/client/hooks/useSessionArtifacts.test.tsx
create mode 100644 packages/web-shell/client/hooks/useSessionArtifacts.ts
diff --git a/package-lock.json b/package-lock.json
index b57fad68398..cca4ce4962c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -901,6 +901,19 @@
"crelt": "^1.0.5"
}
},
+ "node_modules/@codemirror/merge": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
+ "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/highlight": "^1.0.0",
+ "style-mod": "^4.1.0"
+ }
+ },
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
@@ -27366,6 +27379,7 @@
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/language": "^6.10.0",
+ "@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.0",
"@tanstack/react-virtual": "^3.13.26",
diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts
index 306253346b9..5344e939515 100644
--- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts
+++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts
@@ -9,6 +9,7 @@ import type {
DaemonAuthProviderId,
DaemonErrorKind,
DaemonEvent,
+ DaemonSessionArtifactChange,
} from '../types.js';
import { DAEMON_ERROR_KINDS } from '../types.js';
import type {
@@ -299,6 +300,9 @@ export function normalizeDaemonEvent(
case 'extensions_changed':
return normalizeExtensionsChanged(event, base);
+ case 'artifact_changed':
+ return normalizeArtifactChanged(event, base);
+
// ── Auth device-flow events (RFC 8628) ─────────────────
case 'auth_device_flow_started':
return normalizeAuthDeviceFlowStarted(event, base);
@@ -1126,6 +1130,30 @@ function normalizeSessionMetadataUpdated(
];
}
+function normalizeArtifactChanged(
+ event: DaemonEvent,
+ base: NormalizedEventBase,
+): DaemonUiEvent[] {
+ const sessionId = getString(event.data, 'sessionId');
+ const change = isRecord(event.data) ? event.data['change'] : undefined;
+ if (!sessionId || !isRecord(change)) {
+ return fallbackDebug(event, base, 'malformed artifact_changed payload');
+ }
+ const action = getString(change, 'action');
+ const artifactId = getString(change, 'artifactId');
+ if (!action || !artifactId) {
+ return fallbackDebug(event, base, 'missing action or artifactId');
+ }
+ return [
+ {
+ ...base,
+ type: 'session.artifact.changed',
+ sessionId,
+ change: change as unknown as DaemonSessionArtifactChange,
+ },
+ ];
+}
+
function normalizeApprovalModeChanged(
event: DaemonEvent,
base: NormalizedEventBase,
diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts
index 619d7a21389..a12fdd325c5 100644
--- a/packages/sdk-typescript/src/daemon/ui/terminal.ts
+++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts
@@ -58,6 +58,12 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string {
`metadata: ${event.displayName ?? '(no display name)'}`,
'36',
);
+ case 'session.artifact.changed':
+ return terminalLine(
+ 'artifact',
+ `${event.change.action} ${event.change.artifact?.title ?? event.change.artifactId}`,
+ '36',
+ );
case 'session.approval_mode.changed':
return terminalLine(
'approval-mode',
diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts
index bdc8ac1a639..a1ec377ddb2 100644
--- a/packages/sdk-typescript/src/daemon/ui/transcript.ts
+++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts
@@ -293,6 +293,7 @@ function applyDaemonTranscriptEvent(
next.approvalMode = event.next;
break;
case 'session.metadata.changed':
+ case 'session.artifact.changed':
case 'session.available_commands':
// Intentional no-op against `blocks[]`.
break;
diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts
index 0b7be648165..7cd59c90ce8 100644
--- a/packages/sdk-typescript/src/daemon/ui/types.ts
+++ b/packages/sdk-typescript/src/daemon/ui/types.ts
@@ -9,6 +9,7 @@ import type {
DaemonAuthProviderId,
DaemonEvent,
DaemonErrorKind,
+ DaemonSessionArtifactChange,
PermissionResponse,
} from '../types.js';
@@ -34,6 +35,7 @@ export type DaemonUiEventType =
| 'debug'
// Session-meta events
| 'session.metadata.changed'
+ | 'session.artifact.changed'
| 'session.approval_mode.changed'
| 'session.available_commands'
| 'session.state_resync_required'
@@ -281,6 +283,12 @@ export interface DaemonUiSessionMetadataChangedEvent extends DaemonUiEventBase {
displayName?: string;
}
+export interface DaemonUiSessionArtifactChangedEvent extends DaemonUiEventBase {
+ type: 'session.artifact.changed';
+ sessionId: string;
+ change: DaemonSessionArtifactChange;
+}
+
export interface DaemonUiSessionApprovalModeChangedEvent
extends DaemonUiEventBase {
type: 'session.approval_mode.changed';
@@ -556,6 +564,7 @@ export type DaemonUiEvent =
| DaemonUiErrorEvent
// Session-meta events
| DaemonUiSessionMetadataChangedEvent
+ | DaemonUiSessionArtifactChangedEvent
| DaemonUiSessionApprovalModeChangedEvent
| DaemonUiSessionAvailableCommandsEvent
| DaemonUiStateResyncRequiredEvent
diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts
index 868c3485b4b..bd2bd38c3e3 100644
--- a/packages/sdk-typescript/test/unit/daemonUi.test.ts
+++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts
@@ -6651,6 +6651,71 @@ describe('parallel subAgent text interleaving — normalizer', () => {
});
});
+describe('daemon UI normalizer — artifact events', () => {
+ it('normalizes artifact_changed as a structured session event', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: {
+ sessionId: 'session-1',
+ change: {
+ action: 'updated',
+ artifactId: 'artifact-1',
+ artifact: {
+ id: 'artifact-1',
+ title: 'Report',
+ kind: 'html',
+ storage: 'workspace',
+ source: 'tool',
+ status: 'available',
+ },
+ },
+ },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'session.artifact.changed',
+ sessionId: 'session-1',
+ change: expect.objectContaining({
+ action: 'updated',
+ artifactId: 'artifact-1',
+ }),
+ }),
+ ]);
+ });
+
+ it('falls back to debug for malformed artifact_changed payloads', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: { sessionId: 'session-1' },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'debug',
+ text: 'artifact_changed: malformed artifact_changed payload',
+ }),
+ ]);
+ });
+
+ it('falls back to debug when artifact_changed change misses required fields', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: {
+ sessionId: 'session-1',
+ change: {},
+ },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'debug',
+ text: 'artifact_changed: missing action or artifactId',
+ }),
+ ]);
+ });
+});
+
describe('parallel subAgent text interleaving fix', () => {
it('T1: separates text chunks by parentToolCallId into independent blocks', () => {
let state = createDaemonTranscriptState({ now: 1 });
diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css
index 6c290840cfd..ad8067117e5 100644
--- a/packages/web-shell/client/App.module.css
+++ b/packages/web-shell/client/App.module.css
@@ -61,6 +61,23 @@
overflow: hidden;
}
+.artifactResizeHandle {
+ flex: 0 0 8px;
+ width: 8px;
+ margin-left: -4px;
+ cursor: col-resize;
+ position: relative;
+ z-index: 2;
+ touch-action: none;
+}
+
+.artifactResizeHandle::after {
+ content: '';
+ position: absolute;
+ inset: 0 3px;
+ background: transparent;
+}
+
/* Positioning context so the scheduled-tasks page (position:absolute) covers
exactly the chat pane. Only applied while the page is shown, so normal chat
layout is untouched. */
@@ -176,6 +193,12 @@
height: 20px;
}
+@media (max-width: 900px) {
+ .artifactResizeHandle {
+ display: none;
+ }
+}
+
@media (max-width: 760px) {
.mobileDrawer {
display: block;
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index 2b36f5201e7..16fd3955ba2 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -82,6 +82,7 @@ const {
forkSession: vi.fn().mockResolvedValue({ launched: false }),
sendShellCommand: vi.fn().mockResolvedValue(undefined),
getStats: vi.fn().mockResolvedValue({}),
+ loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }),
loadSession: vi.fn().mockResolvedValue(undefined),
},
mockWorkspaceActions: {
@@ -137,6 +138,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
onDismissFollowup: mockFollowup.onDismissFollowup,
}),
useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }),
+ usePromptStatus: () => 'idle',
useSettings: () => ({
settings: [],
setValue: vi.fn().mockResolvedValue(undefined),
@@ -147,7 +149,10 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useTranscriptBlocks: () => testState.blocks,
useTranscriptStore: () => mockStore,
useWorkspaceActions: () => mockWorkspaceActions,
- useWorkspaceEventSignals: () => ({ extensionsVersion: 0 }),
+ useWorkspaceEventSignals: () => ({
+ artifactsVersion: 0,
+ extensionsVersion: 0,
+ }),
}));
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
@@ -367,8 +372,33 @@ vi.doMock('./components/SplitView', async () => {
onExit?: () => void;
sessionIds?: string[];
onPanesChange?: (ids: string[]) => void;
- }) =>
- React.createElement(
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: unknown[],
+ workspaceActions: unknown,
+ ) => void;
+ onRightPanelOpen?: (request: unknown) => void;
+ }) => {
+ const paneActions = {
+ readWorkspaceFile: vi.fn().mockResolvedValue('pane
'),
+ };
+ const artifact = {
+ id: 'pane-artifact',
+ kind: 'report',
+ storage: 'memory',
+ source: 'tool',
+ status: 'available',
+ title: 'Pane artifact',
+ updatedAt: '2026-07-10T00:00:00Z',
+ sizeBytes: 10,
+ };
+ const updatedArtifact = {
+ ...artifact,
+ title: 'Updated pane artifact',
+ updatedAt: '2026-07-10T00:01:00Z',
+ sizeBytes: 20,
+ };
+ return React.createElement(
'div',
{ 'data-testid': 'split-view-mock' },
// Surface the seed so a test can assert the App preserved / restored it.
@@ -387,6 +417,63 @@ vi.doMock('./components/SplitView', async () => {
},
'report',
),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-report-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.(
+ 'pane-session',
+ [artifact],
+ paneActions,
+ ),
+ },
+ 'artifact',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-report-updated-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.(
+ 'pane-session',
+ [updatedArtifact],
+ paneActions,
+ ),
+ },
+ 'updated artifact',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-clear-artifacts',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.('pane-session', [], paneActions),
+ },
+ 'clear artifacts',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-open-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onRightPanelOpen?.({
+ id: 'artifact:pane-artifact:pane-session',
+ kind: 'artifact',
+ title: artifact.title,
+ turnId: 'turn-1',
+ artifactId: artifact.id,
+ artifact,
+ workspaceActions: paneActions,
+ previewContent: 'stale
',
+ }),
+ },
+ 'open artifact',
+ ),
React.createElement(
'button',
{
@@ -396,7 +483,8 @@ vi.doMock('./components/SplitView', async () => {
},
'back',
),
- ),
+ );
+ },
};
});
// Capturing mock: stores the onRunPrompt handler (App's real runTaskManually)
@@ -1451,6 +1539,93 @@ describe('App session callbacks', () => {
).toBe('s1,s2,s3');
});
+ it('reconciles split pane artifact snapshots in the right panel', async () => {
+ const { container } = renderApp();
+ await flush();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="open-split-view"]')
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector('[data-testid="split-open-artifact"]')
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Pane artifact');
+ expect(container.textContent).toContain('10 B');
+
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-updated-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('20 B');
+
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-clear-artifacts"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Artifact not found.');
+ });
+
+ it('clears split pane artifact snapshots when switching sessions', async () => {
+ const { container, rerender } = renderApp();
+ await flush();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="open-split-view"]')
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector('[data-testid="split-open-artifact"]')
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Pane artifact');
+
+ await act(async () => {
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).not.toContain('Pane artifact');
+ });
+
it('enters the split view from a ?split= URL and consumes the param', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index e2f4692564c..e03fa9307fa 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -7,6 +7,7 @@ import {
useRef,
useState,
type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import {
@@ -21,6 +22,7 @@ import {
useTranscriptStore,
useWorkspaceActions,
useWorkspaceEventSignals,
+ type DaemonWorkspaceActions,
type DaemonSessionNotice,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
@@ -28,6 +30,7 @@ import { isDaemonTurnError } from '@qwen-code/sdk/daemon';
import type {
DaemonTranscriptBlock,
DaemonSessionTaskStatus,
+ DaemonSessionArtifact,
} from '@qwen-code/sdk/daemon';
import { extractPendingPermission } from './adapters/transcriptAdapter';
import { MessageList, type MessageListHandle } from './components/MessageList';
@@ -67,6 +70,22 @@ import { ToolsDialog } from './components/dialogs/ToolsDialog';
import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog';
import { SessionOverviewPanel } from './components/SessionOverviewPanel';
import { SplitView } from './components/SplitView';
+import {
+ ArtifactPanel,
+ type ArtifactPanelTab,
+} from './components/artifacts/ArtifactPanel';
+import type {
+ TurnOutputFileChange,
+ TurnOutputKind,
+ TurnOutputOpenRequest,
+ TurnOutputScheduledTask,
+} from './components/artifacts/TurnOutputs';
+import { TURN_OUTPUT_KINDS } from './components/artifacts/TurnOutputs';
+import {
+ getArtifactsByTurn,
+ getFileChangesByTurn,
+ getScheduledTasksByTurn,
+} from './components/artifacts/turnOutputSelectors';
import { useIsLargeScreen } from './hooks/useIsLargeScreen';
import { MAX_SPLIT_PANES, parseSplitSessionIds } from './utils/splitUrl';
import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog';
@@ -90,6 +109,7 @@ import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameValue } from './hooks/useAnimationFrameValue';
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
import { useMessages } from './hooks/useMessages';
+import { useSessionArtifacts } from './hooks/useSessionArtifacts';
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
import {
I18nProvider,
@@ -242,6 +262,23 @@ function TodoContextsProvider({
const MODES_CYCLE = DAEMON_APPROVAL_MODES;
const MAX_TOASTS = 4;
+const DEFAULT_REVIEW_PANEL_WIDTH = 760;
+const MIN_ARTIFACT_PANEL_WIDTH = 320;
+const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500;
+const MAX_ARTIFACT_PANEL_SESSION_STATES = 20;
+interface ArtifactPanelSessionState {
+ open: boolean;
+ tabs: ArtifactPanelTab[];
+ activeTabId: string | null;
+ reviewChanges: readonly TurnOutputFileChange[];
+ selectedReviewPath: string | null;
+ extraArtifacts: DaemonSessionArtifact[];
+ width: number;
+}
+interface PaneArtifactSnapshot {
+ artifacts: readonly DaemonSessionArtifact[];
+ workspaceActions: DaemonWorkspaceActions;
+}
// Cap on how long a manual "run now" waits for its bound session to become
// active before giving up, so the scheduled-tasks UI can't stay stuck disabled
// if the switch never completes.
@@ -398,6 +435,15 @@ export interface WebShellProps {
splitSessionIds?: readonly string[];
/** Called when the split pane list changes from inside WebShell. */
onSplitSessionIdsChange?: (sessionIds: string[]) => void;
+ /**
+ * Called instead of the built-in right panel open behavior when a user clicks
+ * a turn output such as review changes, an artifact, or a scheduled task.
+ */
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ /**
+ * Controls which turn output cards appear below messages. Defaults to all.
+ */
+ messageTurnOutputs?: readonly TurnOutputKind[];
/** Imperative handle for externally opening WebShell surfaces. */
shellRef?: React.Ref;
/** Built-in composer toolbar actions to show. Defaults to all actions. */
@@ -887,6 +933,8 @@ export function App({
sidebar,
splitSessionIds: externalSplitSessionIds,
onSplitSessionIdsChange,
+ onRightPanelOpen,
+ messageTurnOutputs,
shellRef,
composerToolbarActions,
compactThinking = false,
@@ -1085,6 +1133,7 @@ export function App({
const nextRecapMessageIdRef = useRef(1);
const nextBtwMessageIdRef = useRef(1);
const btwAbortControllerRef = useRef(null);
+ const chatPaneRef = useRef(null);
const currentSessionIdRef = useRef(connection.sessionId);
const lastNotifiedSessionIdRef = useRef(undefined);
const lastGoalSessionIdRef = useRef(connection.sessionId);
@@ -1113,6 +1162,502 @@ export function App({
}
return filterModelSwitchMessages(result);
}, [messages, recapMessage]);
+ const {
+ artifacts,
+ loading: artifactsLoading,
+ error: artifactsError,
+ } = useSessionArtifacts();
+ const [artifactPanelExtraArtifacts, setArtifactPanelExtraArtifacts] =
+ useState([]);
+ const [paneArtifactSnapshots, setPaneArtifactSnapshots] = useState<
+ Map
+ >(() => new Map());
+ const [artifactPanelTabs, setArtifactPanelTabs] = useState<
+ ArtifactPanelTab[]
+ >([]);
+ useEffect(() => {
+ if (artifactPanelExtraArtifacts.length === 0 || artifacts.length === 0) {
+ return;
+ }
+ const artifactIds = new Set(artifacts.map((artifact) => artifact.id));
+ const paneArtifactIds = new Set(
+ artifactPanelTabs
+ .filter((tab) => tab.kind === 'artifact' && tab.workspaceActions)
+ .map((tab) => (tab.kind === 'artifact' ? tab.artifactId : '')),
+ );
+ setArtifactPanelExtraArtifacts((previous) => {
+ const next = previous.filter(
+ (artifact) =>
+ !artifactIds.has(artifact.id) || paneArtifactIds.has(artifact.id),
+ );
+ return next.length === previous.length ? previous : next;
+ });
+ }, [artifacts, artifactPanelExtraArtifacts.length, artifactPanelTabs]);
+ const paneArtifactExtras = useMemo(
+ () =>
+ Array.from(paneArtifactSnapshots.values()).flatMap((snapshot) => [
+ ...snapshot.artifacts,
+ ]),
+ [paneArtifactSnapshots],
+ );
+ const artifactPanelArtifacts = useMemo(() => {
+ if (
+ artifactPanelExtraArtifacts.length === 0 &&
+ paneArtifactExtras.length === 0
+ ) {
+ return artifacts;
+ }
+ const merged = [...artifacts];
+ for (const artifact of [
+ ...artifactPanelExtraArtifacts,
+ ...paneArtifactExtras,
+ ]) {
+ const index = merged.findIndex((item) => item.id === artifact.id);
+ if (index < 0) {
+ merged.push(artifact);
+ }
+ }
+ return merged;
+ }, [artifacts, artifactPanelExtraArtifacts, paneArtifactExtras]);
+ const handlePaneArtifactsChange = useCallback(
+ (
+ paneSessionId: string,
+ paneArtifacts: readonly DaemonSessionArtifact[],
+ paneWorkspaceActions: DaemonWorkspaceActions,
+ ) => {
+ setPaneArtifactSnapshots((current) => {
+ const previous = current.get(paneSessionId);
+ const unchanged =
+ previous?.workspaceActions === paneWorkspaceActions &&
+ previous.artifacts.length === paneArtifacts.length &&
+ previous.artifacts.every((artifact, index) => {
+ const nextArtifact = paneArtifacts[index];
+ return (
+ nextArtifact?.id === artifact.id &&
+ nextArtifact.updatedAt === artifact.updatedAt &&
+ nextArtifact.sizeBytes === artifact.sizeBytes
+ );
+ });
+ if (unchanged) return current;
+ const next = new Map(current);
+ if (paneArtifacts.length === 0) {
+ next.delete(paneSessionId);
+ } else {
+ next.set(paneSessionId, {
+ artifacts: [...paneArtifacts],
+ workspaceActions: paneWorkspaceActions,
+ });
+ }
+ return next;
+ });
+ const artifactIds = new Set(paneArtifacts.map((artifact) => artifact.id));
+ setArtifactPanelTabs((tabs) => {
+ let changed = false;
+ const next = tabs.map((tab) => {
+ if (tab.kind !== 'artifact' || !artifactIds.has(tab.artifactId)) {
+ return tab;
+ }
+ const updated = {
+ id: tab.id,
+ kind: 'artifact' as const,
+ title: tab.title,
+ artifactId: tab.artifactId,
+ workspaceActions: tab.workspaceActions ?? paneWorkspaceActions,
+ };
+ if (tab.previewContent !== undefined) changed = true;
+ if (tab.workspaceActions) return updated;
+ changed = true;
+ return updated;
+ });
+ return changed ? next : tabs;
+ });
+ },
+ [],
+ );
+ const artifactsByTurn = useMemo(
+ () =>
+ getArtifactsByTurn(
+ displayMessages,
+ artifacts,
+ connection.workspaceCwd || '',
+ ),
+ [displayMessages, artifacts, connection.workspaceCwd],
+ );
+ const fileChangesByTurn = useMemo(
+ () =>
+ getFileChangesByTurn(
+ displayMessages,
+ artifactsByTurn,
+ connection.workspaceCwd || '',
+ ),
+ [displayMessages, artifactsByTurn, connection.workspaceCwd],
+ );
+ const scheduledTasksByTurn = useMemo(
+ () => getScheduledTasksByTurn(displayMessages),
+ [displayMessages],
+ );
+ const visibleTurnOutputKinds = useMemo(
+ () => new Set(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
+ [messageTurnOutputs],
+ );
+ const [artifactPanelOpen, setArtifactPanelOpen] = useState(false);
+ const artifactPanelOpenRef = useRef(artifactPanelOpen);
+ artifactPanelOpenRef.current = artifactPanelOpen;
+ const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState<
+ string | null
+ >(null);
+ const activeArtifactPanelTabIdRef = useRef(activeArtifactPanelTabId);
+ activeArtifactPanelTabIdRef.current = activeArtifactPanelTabId;
+ const [reviewChanges, setReviewChanges] = useState<
+ readonly TurnOutputFileChange[]
+ >([]);
+ const [selectedReviewPath, setSelectedReviewPath] = useState(
+ null,
+ );
+ const [artifactPanelWidth, setArtifactPanelWidth] = useState(
+ DEFAULT_REVIEW_PANEL_WIDTH,
+ );
+ const artifactPanelResizeCleanupRef = useRef<(() => void) | null>(null);
+ const artifactPanelSessionStateRef = useRef(
+ null,
+ );
+ const artifactPanelStateBySessionRef = useRef(
+ new Map(),
+ );
+ const artifactPanelSessionIdRef = useRef(connection.sessionId);
+ artifactPanelSessionStateRef.current = {
+ open: artifactPanelOpen,
+ tabs: artifactPanelTabs,
+ activeTabId: activeArtifactPanelTabId,
+ reviewChanges,
+ selectedReviewPath,
+ extraArtifacts: artifactPanelExtraArtifacts,
+ width: artifactPanelWidth,
+ };
+ useEffect(() => {
+ const previousSessionId = artifactPanelSessionIdRef.current;
+ if (previousSessionId) {
+ const currentState = artifactPanelSessionStateRef.current;
+ if (currentState) {
+ artifactPanelStateBySessionRef.current.set(
+ previousSessionId,
+ currentState,
+ );
+ if (
+ artifactPanelStateBySessionRef.current.size >
+ MAX_ARTIFACT_PANEL_SESSION_STATES
+ ) {
+ const oldestSessionId = artifactPanelStateBySessionRef.current
+ .keys()
+ .next().value;
+ if (oldestSessionId) {
+ artifactPanelStateBySessionRef.current.delete(oldestSessionId);
+ }
+ }
+ }
+ }
+
+ const nextSessionId = connection.sessionId;
+ artifactPanelSessionIdRef.current = nextSessionId;
+ const savedState = nextSessionId
+ ? artifactPanelStateBySessionRef.current.get(nextSessionId)
+ : undefined;
+ if (!savedState) {
+ setArtifactPanelOpen(false);
+ setArtifactPanelTabs([]);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ setArtifactPanelWidth(DEFAULT_REVIEW_PANEL_WIDTH);
+ return;
+ }
+
+ setArtifactPanelOpen(savedState.open);
+ setArtifactPanelTabs(savedState.tabs);
+ setActiveArtifactPanelTabId(savedState.activeTabId);
+ setReviewChanges(savedState.reviewChanges);
+ setSelectedReviewPath(savedState.selectedReviewPath);
+ setArtifactPanelExtraArtifacts(savedState.extraArtifacts);
+ setPaneArtifactSnapshots(new Map());
+ setArtifactPanelWidth(savedState.width);
+ }, [connection.sessionId]);
+ const getMaxArtifactPanelWidth = useCallback(() => {
+ const chatPaneWidth = chatPaneRef.current?.getBoundingClientRect().width;
+ if (!chatPaneWidth) {
+ return Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ window.innerWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ }
+ return Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ artifactPanelWidth +
+ chatPaneWidth -
+ MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ }, [artifactPanelWidth]);
+ const getDefaultReviewPanelWidth = useCallback(() => {
+ const chatPaneWidth =
+ chatPaneRef.current?.getBoundingClientRect().width ?? window.innerWidth;
+ const maxWidth = Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ return Math.min(
+ maxWidth,
+ Math.max(DEFAULT_REVIEW_PANEL_WIDTH, Math.round(chatPaneWidth * 0.56)),
+ );
+ }, []);
+ const openArtifactPanel = useCallback(
+ (artifactId: string, previewContent?: string) => {
+ if (!artifactId) return;
+ const artifact = artifactPanelArtifacts.find(
+ (item) => item.id === artifactId,
+ );
+ const tab: ArtifactPanelTab = {
+ id: `artifact:${artifactId}`,
+ kind: 'artifact',
+ artifactId,
+ title: artifact?.title ?? 'Artifact',
+ ...(previewContent !== undefined ? { previewContent } : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) =>
+ item.id === tab.id ? { ...item, ...tab } : item,
+ )
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [artifactPanelArtifacts, getDefaultReviewPanelWidth],
+ );
+ const openReviewPanel = useCallback(
+ (changes: readonly TurnOutputFileChange[], selectedPath?: string) => {
+ const reviewTab: ArtifactPanelTab = {
+ id: 'review',
+ kind: 'review',
+ title: t('turnOutputs.review'),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === reviewTab.id)
+ ? tabs
+ : [reviewTab, ...tabs],
+ );
+ setActiveArtifactPanelTabId(reviewTab.id);
+ setReviewChanges(changes);
+ setSelectedReviewPath(selectedPath ?? null);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [getDefaultReviewPanelWidth, t],
+ );
+ const openScheduledTaskPanel = useCallback(
+ (
+ task: TurnOutputScheduledTask,
+ tabWorkspaceActions?: ReturnType,
+ ) => {
+ const tab: ArtifactPanelTab = {
+ id: `scheduled-task:${task.toolCallId}`,
+ kind: 'scheduled_task',
+ title: t('scheduledTasks.title'),
+ task,
+ ...(tabWorkspaceActions
+ ? { workspaceActions: tabWorkspaceActions }
+ : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) => (item.id === tab.id ? tab : item))
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [getDefaultReviewPanelWidth, t],
+ );
+ const handleTurnOutputOpen = useCallback(
+ (request: TurnOutputOpenRequest) => {
+ if (onRightPanelOpen) {
+ onRightPanelOpen(request);
+ return;
+ }
+ if (request.kind === 'review') {
+ openReviewPanel(request.changes, request.selectedPath);
+ return;
+ }
+ if (request.kind === 'scheduled_task') {
+ openScheduledTaskPanel(request.task, request.workspaceActions);
+ return;
+ }
+
+ if (!request.workspaceActions) {
+ setArtifactPanelExtraArtifacts((current) => {
+ const index = current.findIndex(
+ (artifact) => artifact.id === request.artifact.id,
+ );
+ if (index < 0) return [...current, request.artifact];
+ const next = [...current];
+ next[index] = request.artifact;
+ return next;
+ });
+ }
+ const tab: ArtifactPanelTab = {
+ id: request.id,
+ kind: 'artifact',
+ title: request.title,
+ artifactId: request.artifactId,
+ ...(request.workspaceActions
+ ? { workspaceActions: request.workspaceActions }
+ : {}),
+ ...(request.previewContent !== undefined
+ ? { previewContent: request.previewContent }
+ : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) =>
+ item.id === tab.id ? { ...item, ...tab } : item,
+ )
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [
+ getDefaultReviewPanelWidth,
+ onRightPanelOpen,
+ openReviewPanel,
+ openScheduledTaskPanel,
+ ],
+ );
+ const closeArtifactPanel = useCallback(() => {
+ setArtifactPanelOpen(false);
+ setArtifactPanelTabs([]);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ }, []);
+ useLayoutEffect(() => {
+ if (!artifactPanelOpen) return;
+ const clampWidth = () => {
+ setArtifactPanelWidth((width) => {
+ const chatPaneWidth =
+ chatPaneRef.current?.getBoundingClientRect().width ??
+ window.innerWidth - width;
+ const maxWidth = Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ width + chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ return Math.min(width, maxWidth);
+ });
+ };
+ clampWidth();
+ window.addEventListener('resize', clampWidth);
+ const chatPane = chatPaneRef.current;
+ const observer = new ResizeObserver(clampWidth);
+ if (chatPane) observer.observe(chatPane);
+ return () => {
+ window.removeEventListener('resize', clampWidth);
+ observer.disconnect();
+ };
+ }, [artifactPanelOpen]);
+ const closeArtifactPanelTab = useCallback((tabId: string) => {
+ setArtifactPanelTabs((tabs) => {
+ const nextTabs = tabs.filter((tab) => tab.id !== tabId);
+ if (nextTabs.length === 0) {
+ setArtifactPanelOpen(false);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ return nextTabs;
+ }
+ if (activeArtifactPanelTabIdRef.current === tabId) {
+ const closedIndex = tabs.findIndex((tab) => tab.id === tabId);
+ const nextActive =
+ nextTabs[Math.min(closedIndex, nextTabs.length - 1)] ?? nextTabs[0];
+ setActiveArtifactPanelTabId(nextActive.id);
+ }
+ return nextTabs;
+ });
+ }, []);
+ const handleArtifactPanelResizeStart = useCallback(
+ (event: ReactPointerEvent) => {
+ event.preventDefault();
+ const resizeHandle = event.currentTarget;
+ resizeHandle.setPointerCapture(event.pointerId);
+ const startX = event.clientX;
+ const startWidth = artifactPanelWidth;
+ const maxWidth = getMaxArtifactPanelWidth();
+ const previousCursor = document.body.style.cursor;
+ const previousUserSelect = document.body.style.userSelect;
+ let pendingWidth = startWidth;
+ let animationFrame: number | null = null;
+
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const flushWidth = () => {
+ animationFrame = null;
+ setArtifactPanelWidth(pendingWidth);
+ };
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ pendingWidth = Math.min(
+ maxWidth,
+ Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ startWidth - (moveEvent.clientX - startX),
+ ),
+ );
+ if (animationFrame === null) {
+ animationFrame = window.requestAnimationFrame(flushWidth);
+ }
+ };
+ let handlePointerUp: () => void = () => {};
+ const cleanupResize = (commitWidth: boolean) => {
+ artifactPanelResizeCleanupRef.current = null;
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ if (commitWidth) setArtifactPanelWidth(pendingWidth);
+ if (resizeHandle.hasPointerCapture(event.pointerId)) {
+ resizeHandle.releasePointerCapture(event.pointerId);
+ }
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = previousUserSelect;
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', handlePointerUp);
+ };
+ handlePointerUp = () => cleanupResize(true);
+ artifactPanelResizeCleanupRef.current = () => cleanupResize(false);
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointercancel', handlePointerUp);
+ },
+ [artifactPanelWidth, getMaxArtifactPanelWidth],
+ );
+ useEffect(() => () => artifactPanelResizeCleanupRef.current?.(), []);
const messageBlocks = useAnimationFrameValue(blocks);
const rawPendingApproval = useMemo(
() => extractPendingPermission(messageBlocks),
@@ -4797,6 +5342,7 @@ export function App({
)}
@@ -5119,6 +5668,25 @@ export function App({
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
+ turnFileChanges={
+ visibleTurnOutputKinds.has('file')
+ ? fileChangesByTurn
+ : undefined
+ }
+ turnArtifacts={
+ visibleTurnOutputKinds.has('artifact')
+ ? artifactsByTurn
+ : undefined
+ }
+ turnScheduledTasks={
+ visibleTurnOutputKinds.has('scheduled_task')
+ ? scheduledTasksByTurn
+ : undefined
+ }
+ onTurnOutputOpen={handleTurnOutputOpen}
+ onReviewChanges={openReviewPanel}
+ onOpenArtifact={openArtifactPanel}
+ onOpenScheduledTask={openScheduledTaskPanel}
/>
);
@@ -5418,6 +5986,33 @@ export function App({
+ {artifactPanelOpen && (
+ <>
+
+
+ >
+ )}
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index ddfb920e79d..3193cbbdb7c 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -30,6 +30,15 @@ const submitPermission = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
const setModel = vi.fn(async () => ({}) as any);
+const loadArtifacts = vi.fn(async () => ({ artifacts: [] }));
+const daemonActions = {
+ sendPrompt,
+ submitPermission,
+ cancel,
+ setApprovalMode,
+ setModel,
+ loadArtifacts,
+};
const enqueuePrompt = vi.fn(() => true);
const removeQueuedPrompt = vi.fn();
const insertQueuedPrompt = vi.fn();
@@ -41,13 +50,7 @@ let queuedTextsMock: string[] = [];
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
- useActions: () => ({
- sendPrompt,
- submitPermission,
- cancel,
- setApprovalMode,
- setModel,
- }),
+ useActions: () => daemonActions,
useConnection: () => connectionState,
useDaemonFollowupSuggestion: (options: any) => {
latestFollowupAccept = options?.onAccept;
@@ -63,6 +66,9 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useTranscriptStore: () => ({
dispatch: transcriptDispatch,
}),
+ usePromptStatus: () => 'idle',
+ useWorkspaceActions: () => ({}),
+ useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }),
}));
vi.mock('../hooks/useQueuedPrompts', () => ({
@@ -218,6 +224,8 @@ beforeEach(() => {
queuedPromptsMock = [];
queuedTextsMock = [];
sendPrompt.mockReset();
+ loadArtifacts.mockReset();
+ loadArtifacts.mockResolvedValue({ artifacts: [] });
sendPrompt.mockImplementation(async (_text: string, options?: any) => {
sendPromptAdmit = options?.onAdmitted;
return {} as any;
@@ -269,6 +277,32 @@ describe('ChatPane', () => {
expect(container!.textContent).toContain('Refactor core');
});
+ it('reports loaded pane artifacts to the outer panel owner', async () => {
+ const onPaneArtifactsChange = vi.fn();
+ connectionState.capabilities = { features: ['session_artifacts'] };
+ const artifact = {
+ id: 'artifact-1',
+ title: 'Report',
+ kind: 'html',
+ storage: 'workspace',
+ workspacePath: 'reports/a.html',
+ updatedAt: '2026-07-10T00:00:00Z',
+ };
+ loadArtifacts.mockResolvedValueOnce({ artifacts: [artifact] });
+
+ render({ onPaneArtifactsChange });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(onPaneArtifactsChange).toHaveBeenLastCalledWith(
+ 'sess-1',
+ [artifact],
+ expect.any(Object),
+ );
+ });
+
it('suppresses the rotating loading phrase in its compact status', () => {
render();
expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe(
diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx
index d1a40d562e7..5499d7fce69 100644
--- a/packages/web-shell/client/components/ChatPane.tsx
+++ b/packages/web-shell/client/components/ChatPane.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
useActions,
useConnection,
@@ -12,9 +12,13 @@ import {
useStreamingState,
useTranscriptBlocks,
useTranscriptStore,
+ useWorkspaceActions,
+ type DaemonWorkspaceActions,
} from '@qwen-code/webui/daemon-react-sdk';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import { useI18n } from '../i18n';
import { useMessages } from '../hooks/useMessages';
+import { useSessionArtifacts } from '../hooks/useSessionArtifacts';
import { extractPendingPermission } from '../adapters/transcriptAdapter';
import type { PromptImage } from '../adapters/promptTypes';
import type {
@@ -38,6 +42,16 @@ import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { QueuedPromptDisplay } from './QueuedPromptDisplay';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
+import type {
+ TurnOutputKind,
+ TurnOutputOpenRequest,
+} from './artifacts/TurnOutputs';
+import { TURN_OUTPUT_KINDS } from './artifacts/TurnOutputs';
+import {
+ getArtifactsByTurn,
+ getFileChangesByTurn,
+ getScheduledTasksByTurn,
+} from './artifacts/turnOutputSelectors';
import styles from './ChatPane.module.css';
// Split-view panes get the same interactive composer controls as the main chat,
@@ -55,6 +69,13 @@ export interface ChatPaneProps {
title?: string;
onClose?: () => void;
onError?: (error: unknown, fallback: string) => void;
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: readonly DaemonSessionArtifact[],
+ workspaceActions: DaemonWorkspaceActions,
+ ) => void;
+ messageTurnOutputs?: readonly TurnOutputKind[];
}
/**
@@ -64,14 +85,36 @@ export interface ChatPaneProps {
* state, approvals, and composer, and the browser scopes keyboard focus to the
* pane the user clicks into — so there is no cross-pane approval arbitration.
*/
-export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
+export function ChatPane({
+ title,
+ onClose,
+ onError,
+ onRightPanelOpen,
+ onPaneArtifactsChange,
+ messageTurnOutputs,
+}: ChatPaneProps) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
+ const workspaceActions = useWorkspaceActions();
const messages = useMessages(t);
const blocks = useTranscriptBlocks();
const store = useTranscriptStore();
const streamingState = useStreamingState();
+ const { artifacts } = useSessionArtifacts();
+ useEffect(() => {
+ const sessionId = connection.sessionId;
+ if (!sessionId) return;
+ onPaneArtifactsChange?.(sessionId, artifacts, workspaceActions);
+ return () => {
+ onPaneArtifactsChange?.(sessionId, [], workspaceActions);
+ };
+ }, [
+ artifacts,
+ connection.sessionId,
+ onPaneArtifactsChange,
+ workspaceActions,
+ ]);
const streamingStateRef = useRef(streamingState);
streamingStateRef.current = streamingState;
const editorRef = useRef(null);
@@ -115,6 +158,28 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
const approvalActive =
pendingToolApproval !== null || pendingAskUserApproval !== null;
const isResponding = streamingState !== 'idle';
+ const artifactsByTurn = useMemo(
+ () =>
+ getArtifactsByTurn(messages, artifacts, connection.workspaceCwd || ''),
+ [messages, artifacts, connection.workspaceCwd],
+ );
+ const fileChangesByTurn = useMemo(
+ () =>
+ getFileChangesByTurn(
+ messages,
+ artifactsByTurn,
+ connection.workspaceCwd || '',
+ ),
+ [messages, artifactsByTurn, connection.workspaceCwd],
+ );
+ const scheduledTasksByTurn = useMemo(
+ () => getScheduledTasksByTurn(messages),
+ [messages],
+ );
+ const visibleTurnOutputKinds = useMemo(
+ () => new Set(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
+ [messageTurnOutputs],
+ );
const {
queuedPrompts,
queuedTexts,
@@ -196,6 +261,18 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
);
}, [actions, reportError]);
+ const handleRightPanelOpen = useCallback(
+ (request: TurnOutputOpenRequest) => {
+ if (!onRightPanelOpen) return;
+ if (request.kind === 'artifact' || request.kind === 'scheduled_task') {
+ onRightPanelOpen({ ...request, workspaceActions });
+ return;
+ }
+ onRightPanelOpen(request);
+ },
+ [onRightPanelOpen, workspaceActions],
+ );
+
// Composer wiring, all scoped to THIS pane's own DaemonSession context. The
// slash menu lists the session's daemon commands — they run server-side when
// submitted (via sendPrompt), so e.g. `/clear` clears this pane's session, not
@@ -325,6 +402,18 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
isResponding={isResponding}
workspaceCwd={connection.workspaceCwd || ''}
hideSessionTimeline
+ turnFileChanges={
+ visibleTurnOutputKinds.has('file') ? fileChangesByTurn : undefined
+ }
+ turnArtifacts={
+ visibleTurnOutputKinds.has('artifact') ? artifactsByTurn : undefined
+ }
+ turnScheduledTasks={
+ visibleTurnOutputKinds.has('scheduled_task')
+ ? scheduledTasksByTurn
+ : undefined
+ }
+ onTurnOutputOpen={handleRightPanelOpen}
/>
diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts
index 84c9959301b..db216d46e5a 100644
--- a/packages/web-shell/client/components/MessageList.test.ts
+++ b/packages/web-shell/client/components/MessageList.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { Message, TurnCollapseHead } from '../adapters/types';
import {
+ attachTurnOutputs,
applyTurnCollapse,
findDisplayItemIndex,
findTurnIdForIndex,
@@ -15,6 +16,7 @@ import {
VIRTUAL_SCROLL_THRESHOLD,
type DisplayItem,
} from './MessageList';
+import type { TurnOutputFileChange } from './artifacts/TurnOutputs';
function messageRow(
item: DisplayItem,
@@ -315,6 +317,67 @@ describe('groupParallelAgents', () => {
});
});
+describe('attachTurnOutputs', () => {
+ it('keeps outputs for a transcript that starts before a user turn', () => {
+ const message = makeMultiToolGroup('tg1');
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: 'src/app.ts',
+ status: 'modified',
+ toolCallId: 'call-tg1-a',
+ diffs: [{ oldText: 'one\n', newText: 'two\n' }],
+ },
+ ];
+
+ const items = attachTurnOutputs(
+ [{ type: 'message', key: message.id, message }],
+ false,
+ new Map([[message.id, changes]]),
+ );
+
+ expect(items).toHaveLength(2);
+ expect(items[1]).toMatchObject({
+ type: 'turn_outputs',
+ key: message.id,
+ turnId: message.id,
+ changes,
+ });
+ });
+
+ it('keeps outputs for a leading grouped parallel-agent row', () => {
+ const items = groupParallelAgents([
+ makeAgentToolGroup('x1'),
+ makeAgentToolGroup('x2'),
+ ]);
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: 'src/app.ts',
+ status: 'modified',
+ toolCallId: 'call-x1-a',
+ diffs: [{ oldText: 'one\n', newText: 'two\n' }],
+ },
+ ];
+
+ const outputItems = attachTurnOutputs(
+ items,
+ false,
+ new Map([['x1', changes]]),
+ );
+
+ expect(outputItems).toHaveLength(2);
+ expect(outputItems[0]).toMatchObject({
+ type: 'parallel_agents',
+ turnId: 'x1',
+ });
+ expect(outputItems[1]).toMatchObject({
+ type: 'turn_outputs',
+ key: 'x1',
+ turnId: 'x1',
+ changes,
+ });
+ });
+});
+
describe('getTurnTimelineNode', () => {
const item = (
message: Message,
@@ -725,6 +788,7 @@ describe('getDisplayItemVirtualKey', () => {
getDisplayItemVirtualKey({
type: 'parallel_agents',
key: 'header',
+ turnId: 'header',
agents: [makeAgentToolGroup('a').tools[0]],
}),
).toBe('group:header');
diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx
index fa418eba35f..9ff226a9b91 100644
--- a/packages/web-shell/client/components/MessageList.tsx
+++ b/packages/web-shell/client/components/MessageList.tsx
@@ -17,6 +17,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types';
import type { PermissionRequest } from '../adapters/types';
import {
@@ -31,6 +32,12 @@ import {
import { useI18n } from '../i18n';
import { MessageItem } from './MessageItem';
import { MessageTimestamp } from './MessageTimestamp';
+import {
+ TurnOutputs,
+ type TurnOutputFileChange,
+ type TurnOutputOpenRequest,
+ type TurnOutputScheduledTask,
+} from './artifacts/TurnOutputs';
import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup';
import { useSharedNow } from '../hooks/useSharedNow';
import { toolContainsCallId } from './messages/toolFormatting';
@@ -38,6 +45,8 @@ import turnCollapseStyles from './TurnCollapseRow.module.css';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';
+const noopTurnOutputAction = () => undefined;
+
interface MessageListProps {
messages: Message[];
pendingApproval: PermissionRequest | null;
@@ -74,6 +83,16 @@ interface MessageListProps {
onRetryClick?: () => void;
onBranchSession?: () => void;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
+ turnFileChanges?: ReadonlyMap;
+ turnArtifacts?: ReadonlyMap;
+ turnScheduledTasks?: ReadonlyMap;
+ onReviewChanges?: (
+ changes: readonly TurnOutputFileChange[],
+ selectedPath?: string,
+ ) => void;
+ onOpenArtifact?: (artifactId: string, previewContent?: string) => void;
+ onOpenScheduledTask?: (task: TurnOutputScheduledTask) => void;
+ onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
}
function getLastUserMessageId(messages: Message[]): string | null {
@@ -119,12 +138,21 @@ export type DisplayItem =
| {
type: 'parallel_agents';
key: string;
+ turnId: string;
agents: ACPToolCall[];
/**
* Wall-clock time of the first grouped launch, carried so the grouped
* box reveals its time on hover exactly like a standalone message row.
*/
timestamp?: number;
+ }
+ | {
+ type: 'turn_outputs';
+ key: string;
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ artifacts: readonly DaemonSessionArtifact[];
+ scheduledTasks: readonly TurnOutputScheduledTask[];
};
interface LocateFlashTarget {
@@ -305,6 +333,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
+ turnId: grouped[0].id,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
@@ -321,6 +350,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
+ turnId: grouped[0].id,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
@@ -345,6 +375,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
export function getDisplayItemVirtualKey(item: DisplayItem): string {
if (item.type === 'parallel_agents') return `group:${item.key}`;
+ if (item.type === 'turn_outputs') return `outputs:${item.key}`;
if (item.type === 'turn_collapse') {
const liveKey = item.turnCollapse.liveStartedAt;
return liveKey === undefined
@@ -355,6 +386,61 @@ export function getDisplayItemVirtualKey(item: DisplayItem): string {
return `msg:${item.key}`;
}
+export function attachTurnOutputs(
+ items: DisplayItem[],
+ isResponding: boolean,
+ turnFileChanges?: ReadonlyMap,
+ turnArtifacts?: ReadonlyMap,
+ turnScheduledTasks?: ReadonlyMap,
+): DisplayItem[] {
+ if (
+ (!turnFileChanges || turnFileChanges.size === 0) &&
+ (!turnArtifacts || turnArtifacts.size === 0) &&
+ (!turnScheduledTasks || turnScheduledTasks.size === 0)
+ ) {
+ return items;
+ }
+
+ const result: DisplayItem[] = [];
+ let currentTurnId: string | null = null;
+ const pushTurnOutputs = (turnId: string | null, isFinalTurn: boolean) => {
+ if (isFinalTurn && isResponding) return;
+ if (!turnId) return;
+ const changes = turnFileChanges?.get(turnId) ?? [];
+ const artifacts = turnArtifacts?.get(turnId) ?? [];
+ const scheduledTasks = turnScheduledTasks?.get(turnId) ?? [];
+ if (
+ changes.length === 0 &&
+ artifacts.length === 0 &&
+ scheduledTasks.length === 0
+ ) {
+ return;
+ }
+ result.push({
+ type: 'turn_outputs',
+ key: turnId,
+ turnId,
+ changes,
+ artifacts,
+ scheduledTasks,
+ });
+ };
+
+ for (const item of items) {
+ if (item.type === 'message' && isTurnStartMessage(item.message)) {
+ pushTurnOutputs(currentTurnId, false);
+ currentTurnId = item.message.id;
+ } else if (!currentTurnId && item.type === 'message') {
+ currentTurnId = item.message.id;
+ } else if (!currentTurnId && item.type === 'parallel_agents') {
+ currentTurnId = item.turnId;
+ }
+ result.push(item);
+ }
+ pushTurnOutputs(currentTurnId, true);
+ return result;
+}
+
export interface ApplyTurnCollapseOptions {
/**
* Per-turn user override keyed by the turn's user-message id:
@@ -448,6 +534,7 @@ function collectFinalAssistantTurnIds(
*/
function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean {
if (item.type === 'parallel_agents') return true;
+ if (item.type === 'turn_outputs') return false;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') {
return item.items.some((child) => isHideableStep(child, isFinalAnswer));
@@ -503,6 +590,7 @@ export function getTurnTimelineNode(
label: t ? t('timeline.parallelAgents') : 'Parallel agents',
};
}
+ if (item.type === 'turn_outputs') return { kind: 'none' };
if (item.type !== 'message') return { kind: 'none' };
const { message } = item;
@@ -711,6 +799,7 @@ function timelineDetailSnippetForItem(
? t('timeline.parallelAgentsDetail', { count })
: `${count} parallel agent${count === 1 ? '' : 's'}`;
}
+ if (item.type === 'turn_outputs') return '';
if (item.type !== 'message') return '';
return timelineDetailSnippetForMessage(item.message, t);
}
@@ -907,6 +996,7 @@ export function getSessionTimelineSignature(
function isExecutionWorkStep(item: DisplayItem): boolean {
if (item.type === 'parallel_agents') return true;
+ if (item.type === 'turn_outputs') return false;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') return item.items.some(isExecutionWorkStep);
return item.message.role === 'tool_group' || item.message.role === 'plan';
@@ -927,6 +1017,8 @@ function activeExecutionKey(item: DisplayItem): string | null {
return null;
}
+ if (item.type === 'turn_outputs') return null;
+
if (item.type === 'turn_collapse') {
if (item.turnCollapse.liveStartedAt === undefined) return null;
if (
@@ -1009,6 +1101,7 @@ function itemAssistantUsage(item: DisplayItem):
function itemToolCallCount(item: DisplayItem): number {
if (item.type === 'parallel_agents') return item.agents.length;
+ if (item.type === 'turn_outputs') return 0;
if (item.type === 'turn_collapse') return 0;
if (item.type === 'turn_content') {
return item.items.reduce((sum, child) => sum + itemToolCallCount(child), 0);
@@ -1394,6 +1487,8 @@ export function findDisplayItemIndex(
findDisplayItemIndex(item.items, messageId, callId) >= 0
) {
return i;
+ } else if (item.type === 'turn_outputs') {
+ continue;
}
}
return -1;
@@ -1423,6 +1518,7 @@ function displayItemMatchesLocateTarget(
displayItemMatchesLocateTarget(child, target),
);
}
+ if (item.type === 'turn_outputs') return false;
return false;
}
@@ -1649,6 +1745,7 @@ const TurnCollapseRow = memo(function TurnCollapseRow({
function getChatRowClassName(item: DisplayItem): string | undefined {
if (item.type === 'turn_collapse') return styles.turnStatusRow;
+ if (item.type === 'turn_outputs') return styles.turnContentRow;
if (item.type === 'turn_content') {
return styles.turnContentRow;
}
@@ -2088,6 +2185,13 @@ export const MessageList = memo(
onRetryClick,
onBranchSession,
onCanScrollToBottomChange,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ onReviewChanges,
+ onOpenArtifact,
+ onOpenScheduledTask,
+ onTurnOutputOpen,
},
ref,
) {
@@ -2101,8 +2205,21 @@ export const MessageList = memo(
[compactMode, messages, pendingApproval],
);
const displayItems = useMemo(
- () => groupParallelAgents(mergedMessages),
- [mergedMessages],
+ () =>
+ attachTurnOutputs(
+ groupParallelAgents(mergedMessages),
+ isResponding,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ ),
+ [
+ mergedMessages,
+ isResponding,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ ],
);
const [isSessionTimelineVisible, setIsSessionTimelineVisible] =
useState(false);
@@ -3126,6 +3243,24 @@ export const MessageList = memo(
);
}
+ if (displayItem.type === 'turn_outputs') {
+ return (
+
+ );
+ }
+
if (displayItem.type === 'turn_collapse') {
return (
void;
onError?: (error: unknown, fallback: string) => void;
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: readonly DaemonSessionArtifact[],
+ workspaceActions: DaemonWorkspaceActions,
+ ) => void;
+ messageTurnOutputs?: readonly TurnOutputKind[];
/**
* Bumped by the parent whenever the session list changes elsewhere (create /
* delete / rename). The "add pane" picker reloads on a change so it never
@@ -56,6 +69,9 @@ export function SplitView({
onPanesChange,
onExit,
onError,
+ onRightPanelOpen,
+ onPaneArtifactsChange,
+ messageTurnOutputs,
sessionListReloadToken,
}: SplitViewProps) {
const { t } = useI18n();
@@ -325,6 +341,9 @@ export function SplitView({
title={titleById.get(sessionId)}
onClose={() => removePane(sessionId)}
onError={onError}
+ onRightPanelOpen={onRightPanelOpen}
+ onPaneArtifactsChange={onPaneArtifactsChange}
+ messageTurnOutputs={messageTurnOutputs}
/>
diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css
new file mode 100644
index 00000000000..34ec9ee4642
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css
@@ -0,0 +1,807 @@
+.panel {
+ flex: 0 0 min(420px, 36vw);
+ min-width: 320px;
+ border-left: 1px solid var(--border);
+ background: var(--background);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.header {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 14px 0 14px;
+}
+
+.title {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.tabs {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+}
+
+.tabItem {
+ appearance: none;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--muted-foreground);
+ border-radius: 6px;
+ max-width: 180px;
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+}
+
+.tab {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ min-width: 0;
+ overflow: hidden;
+ padding: 5px 4px 5px 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.tabIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ opacity: 0.7;
+}
+
+.tabIconSvg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.tabItem:hover .tabIcon,
+.tabActive .tabIcon {
+ opacity: 1;
+}
+
+.tabItem:hover {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.tabActive {
+ background: var(--accent);
+ border-color: var(--border);
+ color: var(--foreground);
+}
+
+.tabCloseButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border-radius: 5px;
+}
+
+.tabCloseButton:hover {
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.tabCloseIcon {
+ width: 13px;
+ height: 13px;
+ display: block;
+}
+
+.iconButton {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground);
+ border-radius: 6px;
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.iconButton:hover {
+ background: var(--accent);
+}
+
+.iconButtonActive {
+ background: var(--accent);
+}
+
+.toolbarIcon {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px;
+}
+
+.empty {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ padding: 16px 4px;
+}
+
+.list {
+ display: grid;
+ gap: 6px;
+}
+
+.row {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground);
+ border-radius: 6px;
+ padding: 8px;
+ display: grid;
+ gap: 4px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.row:hover,
+.rowActive {
+ background: var(--accent);
+}
+
+.rowTitle {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.rowMeta,
+.meta {
+ color: var(--muted-foreground);
+ font-size: 12px;
+}
+
+.detail {
+ display: grid;
+ gap: 12px;
+}
+
+.section {
+ display: grid;
+ gap: 6px;
+}
+
+.sectionTitle {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.description {
+ white-space: pre-wrap;
+}
+
+.actionsRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.fieldGrid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 4px;
+ font-size: 13px;
+}
+
+.fieldLabel {
+ color: var(--muted-foreground);
+ margin-top: 8px;
+}
+
+.fieldLabel:first-child {
+ margin-top: 0;
+}
+
+.fieldValue {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.link {
+ color: var(--link-color, #2563eb);
+ text-decoration: none;
+}
+
+.link:hover {
+ text-decoration: underline;
+}
+
+.review {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 12px;
+}
+
+.reviewToolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--foreground);
+ font-weight: 600;
+}
+
+.reviewToolbarTitle {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.reviewToolbarActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reviewTotalsButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.reviewTotalsButton:hover {
+ color: var(--foreground);
+}
+
+.reviewContent {
+ min-height: 0;
+ display: grid;
+ grid-template-columns:
+ minmax(180px, var(--review-list-width, 520px))
+ 8px minmax(0, 1fr);
+ border-top: 1px solid var(--border);
+ overflow: hidden;
+}
+
+.reviewContentListOnly {
+ grid-template-columns: 1fr;
+}
+
+.reviewContentStacked {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.reviewList {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.reviewListWithExpanded {
+ overflow: hidden;
+}
+
+.reviewContentListOnly .reviewList {
+ border-right: 0;
+}
+
+.reviewContentStacked .reviewList {
+ border-bottom: 1px solid var(--border);
+ max-height: none;
+}
+
+.reviewSplitHandle {
+ min-width: 8px;
+ cursor: col-resize;
+ position: relative;
+ touch-action: none;
+ border-left: 1px solid var(--border);
+}
+
+.reviewSplitHandle::after {
+ content: '';
+ position: absolute;
+ inset: 0 3px;
+ background: transparent;
+}
+
+.reviewItem {
+ min-width: 0;
+ flex: 0 0 auto;
+}
+
+.reviewItemExpanded {
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.reviewRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ padding: 8px 10px;
+ text-align: left;
+}
+
+.reviewRow:hover {
+ background: var(--accent);
+}
+
+.fileIcon {
+ min-width: 24px;
+ height: 20px;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--muted-foreground);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.reviewPath,
+.treeName {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.reviewPath {
+ display: inline-flex;
+ width: 100%;
+ max-width: 100%;
+}
+
+.treeName {
+ text-overflow: ellipsis;
+}
+
+.pathPrefix {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ color: var(--muted-foreground);
+}
+
+.pathFileName {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lineStats {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.additions {
+ color: #16a34a;
+}
+
+.deletions {
+ color: #dc2626;
+}
+
+.chevron {
+ color: var(--muted-foreground);
+ display: inline-flex;
+ transition: transform 120ms ease;
+}
+
+.chevronOpen {
+ transform: rotate(90deg);
+}
+
+.chevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+}
+
+.diffPreview {
+ border: 1px solid var(--border);
+ overflow: hidden;
+ background: var(--muted, rgba(0, 0, 0, 0.03));
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.codeMirrorDiff {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ overflow: auto;
+ background: var(--background);
+}
+
+.codeMirrorDiffWrap {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.diffError {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ padding: 8px 10px;
+ border-top: 1px solid var(--border);
+ background: var(--background);
+}
+
+.codeMirrorDiff :global(.cm-editor) {
+ font-size: 12px;
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-mergeView) {
+ min-width: max-content;
+}
+
+.codeMirrorDiff :global(.cm-scroller) {
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+}
+
+.codeMirrorDiff :global(.cm-gutters),
+.codeMirrorFile :global(.cm-gutters) {
+ background: var(--background);
+ border-right: 1px solid var(--border);
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-gutter),
+.codeMirrorFile :global(.cm-gutter),
+.codeMirrorDiff :global(.cm-lineNumbers .cm-gutterElement),
+.codeMirrorFile :global(.cm-lineNumbers .cm-gutterElement) {
+ background: transparent;
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-activeLineGutter),
+.codeMirrorFile :global(.cm-activeLineGutter) {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-collapsedLines),
+.codeMirrorFile :global(.cm-collapsedLines) {
+ background: var(--muted);
+ border-color: var(--border);
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-collapsedLines:hover),
+.codeMirrorFile :global(.cm-collapsedLines:hover) {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-mergeView .cm-editor) {
+ min-width: 0;
+}
+
+.codeMirrorDiff :global(.cm-line),
+.codeMirrorDiff :global(.cm-deletedLine) {
+ position: relative;
+ padding-left: 18px;
+}
+
+.codeMirrorDiff :global(.cm-line::before),
+.codeMirrorDiff :global(.cm-deletedLine::before) {
+ content: '';
+ position: absolute;
+ left: 4px;
+ font-weight: 700;
+}
+
+.codeMirrorDiff :global(.cm-merge-a .cm-line.cm-changedLine::before),
+.codeMirrorDiff :global(.cm-deletedLine::before) {
+ content: '-';
+ color: #dc2626;
+}
+
+.codeMirrorDiff :global(.cm-merge-b .cm-line.cm-changedLine::before) {
+ content: '+';
+ color: #16a34a;
+}
+
+.codeMirrorDiff :global(.cm-merge-b .cm-changedText) {
+ background: transparent;
+}
+
+.diffEmpty {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+}
+
+.tree {
+ min-width: 0;
+ overflow: auto;
+ padding: 8px 0;
+}
+
+.treeNode {
+ position: relative;
+}
+
+.treeChildren {
+ position: relative;
+}
+
+.treeChildren::before {
+ content: '';
+ position: absolute;
+ top: -2px;
+ bottom: 8px;
+ left: var(--tree-children-line-left);
+ border-left: 1px dashed var(--border);
+ pointer-events: none;
+}
+
+.treeRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ padding-right: 10px;
+ color: var(--foreground);
+ position: relative;
+}
+
+.treeRow:hover {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.treeRow[data-depth]:not([data-depth='0'])::before {
+ content: '';
+ position: absolute;
+ left: var(--tree-row-line-left);
+ top: 50%;
+ width: 9px;
+ border-top: 1px dashed var(--border);
+ pointer-events: none;
+}
+
+.treeFolder {
+ color: var(--foreground);
+}
+
+.treeFile {
+ color: var(--muted-foreground);
+}
+
+button.treeRow {
+ cursor: pointer;
+ color: var(--foreground);
+}
+
+.treeTwisty {
+ color: inherit;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 20px;
+ line-height: 1;
+}
+
+.treeChevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+}
+
+.treeChevron {
+ display: inline-flex;
+ transition: transform 120ms ease;
+}
+
+.treeChevronClosed {
+ transform: rotate(-90deg);
+}
+
+.treeContent {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: inherit;
+}
+
+.treeRow .treeName {
+ color: inherit;
+}
+
+.treeFile:not(:hover) .treeName,
+.treeFile:not(:hover) .treeContent,
+.treeFile:not(:hover) .treeTwisty,
+.treeFile:not(:hover) .treeChevron {
+ color: var(--muted-foreground);
+}
+
+button.treeRow:hover {
+ color: var(--foreground);
+}
+
+.reviewBadge {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 1px 6px;
+ color: var(--muted-foreground);
+ font-size: 11px;
+}
+
+.treeRow:hover .reviewBadge,
+.treeRow:hover .fileIcon {
+ color: var(--foreground);
+}
+
+.htmlPreviewWrap {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.htmlPreview {
+ width: 100%;
+ flex: 1 1 auto;
+ min-height: 520px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ display: block;
+}
+
+.previewError {
+ margin-top: 8px;
+ color: var(--muted-foreground);
+ font-size: 12px;
+}
+
+.filePreviewWrap {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.codeMirrorFile {
+ min-width: 0;
+ flex: 1 1 auto;
+ min-height: 520px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--background);
+}
+
+.codeMirrorFile :global(.cm-editor) {
+ min-height: 520px;
+ font-size: 12px;
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.codeMirrorFile :global(.cm-scroller) {
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+}
+
+@media (max-width: 900px) {
+ .panel {
+ position: fixed;
+ inset: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) auto;
+ z-index: 60;
+ width: min(420px, 100vw);
+ max-width: 100vw;
+ box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18);
+ }
+
+ .reviewContent {
+ grid-template-columns: 1fr !important;
+ }
+
+ .reviewSplitHandle {
+ display: none;
+ }
+
+ .reviewList {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ max-height: 220px;
+ }
+
+ .reviewContentListOnly .reviewList {
+ border-bottom: 0;
+ max-height: none;
+ }
+}
diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx
new file mode 100644
index 00000000000..a7d282515cc
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx
@@ -0,0 +1,1814 @@
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import {
+ useWorkspaceActions,
+ type DaemonWorkspaceActions,
+ type DaemonScheduledTask,
+} from '@qwen-code/webui/daemon-react-sdk';
+import { EditorState } from '@codemirror/state';
+import { basicSetup, EditorView } from 'codemirror';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
+} from 'react';
+import { useI18n } from '../../i18n';
+import { DialogShell } from '../dialogs/DialogShell';
+import { isSafeHref } from '../messages/Markdown';
+import {
+ buildCron,
+ describeCron,
+ parseCronToBuilder,
+ type BuilderState,
+ type Frequency,
+} from '../dialogs/scheduledTasksSchedule';
+import taskStyles from '../dialogs/ScheduledTasksDialog.module.css';
+import {
+ artifactKindLabel,
+ formatArtifactSize,
+ getArtifactLocation,
+ normalizePath,
+ withArtifactPreviewCsp,
+} from './artifactUtils';
+import {
+ displayPath,
+ type TurnOutputFileChange,
+ type TurnOutputFileDiff,
+ type TurnOutputScheduledTask,
+} from './TurnOutputs';
+import { LineStats, sumLineStats } from './LineStats';
+import styles from './ArtifactPanel.module.css';
+
+const MIN_PANEL_WIDTH_FOR_DEFAULT_TREE = 740;
+const MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700;
+const FREQUENCIES: Frequency[] = [
+ 'daily',
+ 'weekdays',
+ 'weekly',
+ 'hourly',
+ 'minutes',
+ 'custom',
+];
+const MINUTE_INTERVALS = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30];
+
+export type ArtifactPanelTab =
+ | {
+ id: string;
+ kind: 'review';
+ title: string;
+ }
+ | {
+ id: string;
+ kind: 'artifact';
+ title: string;
+ artifactId: string;
+ workspaceActions?: DaemonWorkspaceActions;
+ previewContent?: string;
+ }
+ | {
+ id: string;
+ kind: 'scheduled_task';
+ title: string;
+ task: TurnOutputScheduledTask;
+ workspaceActions?: DaemonWorkspaceActions;
+ };
+
+interface ArtifactPanelProps {
+ artifacts: readonly DaemonSessionArtifact[];
+ tabs: readonly ArtifactPanelTab[];
+ activeTabId: string | null;
+ reviewChanges: readonly TurnOutputFileChange[];
+ selectedReviewPath: string | null;
+ panelWidth?: number;
+ workspaceCwd?: string;
+ loading?: boolean;
+ error?: string | null;
+ onSelectTab: (tabId: string) => void;
+ onCloseTab: (tabId: string) => void;
+ onClose: () => void;
+}
+
+export function ArtifactPanel({
+ artifacts,
+ tabs,
+ activeTabId,
+ reviewChanges,
+ selectedReviewPath,
+ panelWidth,
+ workspaceCwd,
+ loading,
+ error,
+ onSelectTab,
+ onCloseTab,
+ onClose,
+}: ArtifactPanelProps) {
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
+ const defaultWorkspaceActions = useWorkspaceActions();
+ const activeWorkspaceActions =
+ activeTab?.kind === 'artifact' || activeTab?.kind === 'scheduled_task'
+ ? (activeTab.workspaceActions ?? defaultWorkspaceActions)
+ : defaultWorkspaceActions;
+
+ return (
+
+ );
+}
+
+function CloseIcon() {
+ return (
+
+ );
+}
+
+function TabReviewIcon() {
+ return (
+
+ );
+}
+
+function TabArtifactIcon() {
+ return (
+
+ );
+}
+
+function TabScheduledTaskIcon() {
+ return (
+
+ );
+}
+
+function ArtifactDetailTab({
+ artifacts,
+ artifactId,
+ workspaceActions,
+ previewContent,
+ loading,
+ error,
+}: {
+ artifacts: readonly DaemonSessionArtifact[];
+ artifactId: string;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+ loading?: boolean;
+ error?: string | null;
+}) {
+ const artifact = artifacts.find((item) => item.id === artifactId);
+ if (artifact) {
+ return (
+
+ );
+ }
+ if (loading) {
+ return Loading artifact...
;
+ }
+ if (error) {
+ return {error}
;
+ }
+ return Artifact not found.
;
+}
+
+function ScheduledTaskDetail({
+ task,
+ actions,
+}: {
+ task: TurnOutputScheduledTask;
+ actions: DaemonWorkspaceActions;
+}) {
+ const { t } = useI18n();
+ const [loadedTask, setLoadedTask] = useState(
+ null,
+ );
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [name, setName] = useState('');
+ const [prompt, setPrompt] = useState(task.prompt);
+ const [builder, setBuilder] = useState(() =>
+ parseCronToBuilder(task.cron),
+ );
+ const [showForm, setShowForm] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [formError, setFormError] = useState(null);
+
+ const loadTask = useCallback(async () => {
+ if (!task.durable) {
+ setLoadedTask(null);
+ setName('');
+ setPrompt(task.prompt);
+ setBuilder(parseCronToBuilder(task.cron));
+ setLoadError(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setLoadError(null);
+ try {
+ const tasks = await actions.listScheduledTasks();
+ const match = tasks.find((item) => item.id === task.id) ?? null;
+ setLoadedTask(match);
+ if (match) {
+ setName(match.name ?? '');
+ setPrompt(match.prompt);
+ setBuilder(parseCronToBuilder(match.cron));
+ } else {
+ setName('');
+ setPrompt(task.prompt);
+ setBuilder(parseCronToBuilder(task.cron));
+ }
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [actions, task.cron, task.durable, task.id, task.prompt]);
+
+ useEffect(() => {
+ void loadTask();
+ }, [loadTask]);
+
+ const isSessionScoped = !task.durable;
+ const isDeleted = task.durable && !loading && !loadError && !loadedTask;
+ const canEdit = Boolean(loadedTask);
+ const detailTitle = loadedTask?.name || loadedTask?.prompt || task.title;
+ const detailPrompt = loadedTask?.prompt ?? task.prompt;
+ const detailCron = loadedTask?.cron ?? task.cron;
+ const detailRecurring = loadedTask?.recurring ?? task.recurring;
+ const detailEnabled = loadedTask?.enabled;
+
+ const openEdit = useCallback(() => {
+ if (!loadedTask) return;
+ setName(loadedTask.name ?? '');
+ setPrompt(loadedTask.prompt);
+ setBuilder(parseCronToBuilder(loadedTask.cron));
+ setFormError(null);
+ setShowForm(true);
+ }, [loadedTask]);
+
+ const closeEdit = useCallback(() => {
+ setShowForm(false);
+ setFormError(null);
+ if (!loadedTask) return;
+ setName(loadedTask.name ?? '');
+ setPrompt(loadedTask.prompt);
+ setBuilder(parseCronToBuilder(loadedTask.cron));
+ }, [loadedTask]);
+
+ const handleSave = useCallback(async () => {
+ if (!loadedTask) return;
+ const cron = buildCron(builder);
+ if (!cron) {
+ setFormError(t('scheduledTasks.error.invalidSchedule'));
+ return;
+ }
+ if (prompt.trim().length === 0) {
+ setFormError(t('scheduledTasks.error.emptyPrompt'));
+ return;
+ }
+ setSubmitting(true);
+ setFormError(null);
+ try {
+ const updated = await actions.updateScheduledTask(loadedTask.id, {
+ cron,
+ prompt: prompt.trim(),
+ name: name.trim() || null,
+ });
+ setLoadedTask(updated);
+ setName(updated.name ?? '');
+ setPrompt(updated.prompt);
+ setBuilder(parseCronToBuilder(updated.cron));
+ setShowForm(false);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }, [actions, builder, loadedTask, name, prompt, t]);
+
+ const handleToggle = useCallback(async () => {
+ if (!loadedTask) return;
+ setBusy(true);
+ setFormError(null);
+ try {
+ const updated = await actions.updateScheduledTask(loadedTask.id, {
+ enabled: !loadedTask.enabled,
+ });
+ setLoadedTask(updated);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }, [actions, loadedTask]);
+
+ const handleDelete = useCallback(async () => {
+ if (!loadedTask) return;
+ setBusy(true);
+ setFormError(null);
+ try {
+ await actions.deleteScheduledTask(loadedTask.id);
+ setLoadedTask(null);
+ setShowDeleteConfirm(false);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }, [actions, loadedTask]);
+
+ const previewCron = buildCron(builder);
+ const previewLabel = previewCron ? describeCron(previewCron, t) : null;
+
+ return (
+
+ {loading && (
+
{t('scheduledTasks.loading')}
+ )}
+ {loadError &&
{loadError}
}
+ {isDeleted && (
+
+ {t('scheduledTasks.deletedSnapshot')}
+
+ )}
+ {isSessionScoped && (
+
+ {t('scheduledTasks.sessionScopedSnapshot')}
+
+ )}
+ {!isDeleted && (
+
+
+
+ {t('scheduledTasks.name')}
+
+ {detailTitle}
+
+ {t('scheduledTasks.taskId')}
+
+ {task.id}
+
+ {t('scheduledTasks.schedule')}
+
+
+ {describeCron(detailCron, t)}
+
+ Cron
+ {detailCron}
+
+ {t('scheduledTasks.type')}
+
+
+ {detailRecurring
+ ? t('scheduledTasks.repeats')
+ : t('scheduledTasks.runsOnce')}
+
+ {detailEnabled !== undefined && (
+ <>
+
+ {t('scheduledTasks.status')}
+
+
+ {detailEnabled
+ ? t('scheduledTasks.enable')
+ : t('scheduledTasks.disable')}
+
+ >
+ )}
+
+
+ )}
+
+ {!isDeleted && (
+
+
Prompt
+
{detailPrompt}
+
+ )}
+
+ {formError &&
{formError}
}
+
+
+
+
+
+
+
+ {showDeleteConfirm && loadedTask && (
+
setShowDeleteConfirm(false)}
+ >
+
+
+ {t('scheduledTasks.deleteConfirm', {
+ name: loadedTask.name || loadedTask.prompt,
+ })}
+
+ {formError && (
+
{formError}
+ )}
+
+
+
+
+
+
+ )}
+
+ {showForm && (
+
+
+
+
+
+
+
+
+
+ {(builder.frequency === 'daily' ||
+ builder.frequency === 'weekdays' ||
+ builder.frequency === 'weekly') && (
+
+ )}
+
+ {builder.frequency === 'weekly' && (
+
+ )}
+
+ {builder.frequency === 'minutes' && (
+
+ )}
+
+ {builder.frequency === 'custom' && (
+
+ )}
+
+
+
+ {previewLabel ? (
+ <>
+
+ {previewLabel}
+
+ {previewCron}
+ >
+ ) : (
+
+ {t('scheduledTasks.error.invalidSchedule')}
+
+ )}
+
+
+ {formError && (
+
{formError}
+ )}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function ReviewChanges({
+ changes,
+ selectedPath,
+ panelWidth,
+ workspaceCwd,
+}: {
+ changes: readonly TurnOutputFileChange[];
+ selectedPath: string | null;
+ panelWidth?: number;
+ workspaceCwd?: string;
+}) {
+ const { t } = useI18n();
+ const [isTreeOpen, setIsTreeOpen] = useState(
+ () => !panelWidth || panelWidth >= MIN_PANEL_WIDTH_FOR_DEFAULT_TREE,
+ );
+ const [isFileListOpen, setIsFileListOpen] = useState(true);
+ const [isReviewStacked, setIsReviewStacked] = useState(false);
+ const [reviewListWidth, setReviewListWidth] = useState(520);
+ const reviewListWidthRef = useRef(reviewListWidth);
+ const reviewContentRef = useRef(null);
+ const reviewResizeCleanupRef = useRef<(() => void) | null>(null);
+ const [expandedPath, setExpandedPath] = useState(null);
+ const showTree = isTreeOpen;
+ const fileTree = useMemo(
+ () => buildFileTree(changes, workspaceCwd),
+ [changes, workspaceCwd],
+ );
+
+ useEffect(() => {
+ setExpandedPath(selectedPath);
+ }, [selectedPath]);
+
+ useEffect(() => {
+ reviewListWidthRef.current = reviewListWidth;
+ }, [reviewListWidth]);
+
+ useEffect(() => {
+ const container = reviewContentRef.current;
+ if (!container) return;
+ const update = () => {
+ setIsReviewStacked(container.clientWidth < MAX_REVIEW_SIDE_BY_SIDE_WIDTH);
+ };
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [isFileListOpen]);
+
+ useEffect(() => () => reviewResizeCleanupRef.current?.(), []);
+
+ const handleReviewSplitResizeStart = useCallback(
+ (event: ReactPointerEvent) => {
+ const container = reviewContentRef.current;
+ if (!container) return;
+ event.preventDefault();
+ const resizeHandle = event.currentTarget;
+ resizeHandle.setPointerCapture(event.pointerId);
+ const startX = event.clientX;
+ const startWidth = reviewListWidthRef.current;
+ const containerWidth = container.getBoundingClientRect().width;
+ const maxWidth = Math.max(180, containerWidth - 180);
+ const previousCursor = document.body.style.cursor;
+ const previousUserSelect = document.body.style.userSelect;
+ let pendingWidth = startWidth;
+ let animationFrame: number | null = null;
+
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const flushWidth = () => {
+ animationFrame = null;
+ setReviewListWidth(pendingWidth);
+ };
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ pendingWidth = Math.min(
+ maxWidth,
+ Math.max(180, startWidth + (moveEvent.clientX - startX)),
+ );
+ if (animationFrame === null) {
+ animationFrame = window.requestAnimationFrame(flushWidth);
+ }
+ };
+ let handlePointerUp: () => void = () => {};
+ const cleanupResize = (commitWidth: boolean) => {
+ reviewResizeCleanupRef.current = null;
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ if (commitWidth) setReviewListWidth(pendingWidth);
+ if (resizeHandle.hasPointerCapture(event.pointerId)) {
+ resizeHandle.releasePointerCapture(event.pointerId);
+ }
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = previousUserSelect;
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', handlePointerUp);
+ };
+ handlePointerUp = () => cleanupResize(true);
+ reviewResizeCleanupRef.current = () => cleanupResize(false);
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointercancel', handlePointerUp);
+ },
+ [],
+ );
+ if (changes.length === 0) {
+ return No file changes to review.
;
+ }
+
+ const totals = sumLineStats(changes);
+ const toggleDiff = (path: string) => {
+ setExpandedPath((current) => (current === path ? null : path));
+ };
+
+ return (
+
+
+
+ {t('turnOutputs.previousTurn')}
+
+
+
+
+
+
+
+ {isFileListOpen && (
+
+
+ {changes.map((change) => {
+ const isExpanded = expandedPath === change.path;
+ return (
+
+
+ {isExpanded &&
}
+
+ );
+ })}
+
+ {showTree && !isReviewStacked && (
+
+ )}
+ {showTree && (
+
+ {fileTree.children.map((child) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+function DiffPreview({ change }: { change: TurnOutputFileChange }) {
+ if (change.diffs.length === 0) {
+ return No diff available.
;
+ }
+ const diffs = getDisplayDiffs(change.diffs);
+ return (
+
+ {diffs.map((diff, index) => (
+
+ ))}
+
+ );
+}
+
+function getDisplayDiffs(
+ diffs: readonly TurnOutputFileDiff[],
+): readonly TurnOutputFileDiff[] {
+ for (let index = diffs.length - 1; index >= 0; index--) {
+ const diff = diffs[index];
+ if (diff?.fullContent) return diffs.slice(index);
+ }
+ return diffs;
+}
+
+function CodeMirrorDiff({
+ oldText,
+ newText,
+}: {
+ oldText: string;
+ newText: string;
+}) {
+ const hostRef = useRef(null);
+ const [isWide, setIsWide] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ const update = () => setIsWide(host.clientWidth >= 720);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(host);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host || isWide === null) return;
+ host.replaceChildren();
+ setError(null);
+ let cancelled = false;
+ let view: { destroy(): void } | null = null;
+
+ const extensions = [
+ basicSetup,
+ EditorView.editable.of(false),
+ EditorState.readOnly.of(true),
+ EditorView.lineWrapping,
+ ];
+ const diffConfig = { scanLimit: 1_000, timeout: 500 };
+ const collapseUnchanged = { margin: 3, minSize: 8 };
+
+ void import('@codemirror/merge')
+ .then(({ MergeView, unifiedMergeView }) => {
+ if (cancelled) return;
+ try {
+ if (isWide) {
+ view = new MergeView({
+ a: { doc: oldText, extensions },
+ b: { doc: newText, extensions },
+ parent: host,
+ highlightChanges: true,
+ gutter: true,
+ revertControls: undefined,
+ collapseUnchanged,
+ diffConfig,
+ });
+ return;
+ }
+
+ view = new EditorView({
+ doc: newText,
+ extensions: [
+ ...extensions,
+ unifiedMergeView({
+ original: oldText,
+ highlightChanges: true,
+ gutter: true,
+ mergeControls: false,
+ allowInlineDiffs: true,
+ collapseUnchanged,
+ diffConfig,
+ }),
+ ],
+ parent: host,
+ });
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ return () => {
+ cancelled = true;
+ view?.destroy();
+ };
+ }, [isWide, newText, oldText]);
+
+ return (
+
+
+ {error && (
+
Diff unavailable: {error}
+ )}
+
+ );
+}
+
+interface FileTreeNode {
+ name: string;
+ path: string;
+ file?: TurnOutputFileChange;
+ children: FileTreeNode[];
+}
+
+function TreeNode({
+ node,
+ depth,
+ selectedPath,
+}: {
+ node: FileTreeNode;
+ depth: number;
+ selectedPath: string | null;
+}) {
+ const isFile = Boolean(node.file);
+ const [isOpen, setIsOpen] = useState(true);
+ const rowClassName = [
+ styles.treeRow,
+ isFile ? styles.treeFile : styles.treeFolder,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ const rowStyle = {
+ paddingLeft: 10 + depth * 18,
+ '--tree-row-line-left': `${19 + Math.max(0, depth - 1) * 18}px`,
+ } as CSSProperties;
+ const childrenStyle = {
+ '--tree-children-line-left': `${19 + depth * 18}px`,
+ } as CSSProperties;
+ const rowContent = (
+ <>
+
+ {!isFile && (
+
+
+
+ )}
+
+
+ {isFile && (
+
+ {fileExtensionLabel(node.path)}
+
+ )}
+ {node.name}
+
+ {node.file?.isArtifact && (
+ artifact
+ )}
+ >
+ );
+
+ return (
+
+ {isFile ? (
+
+ {rowContent}
+
+ ) : (
+
+ )}
+ {!isFile && isOpen && node.children.length > 0 && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function PathText({ path, title }: { path: string; title?: string }) {
+ const ref = useRef(null);
+ const [display, setDisplay] = useState(() => splitReviewPath(path));
+ useLayoutEffect(() => {
+ const node = ref.current;
+ if (!node) return;
+ const update = () => setDisplay(compactReviewPath(path, node));
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [path]);
+ return (
+
+ {display.prefix && (
+ {display.prefix}
+ )}
+ {display.leaf}
+
+ );
+}
+
+function splitReviewPath(path: string) {
+ const slashIndex = path.lastIndexOf('/');
+ return slashIndex < 0
+ ? { prefix: '', leaf: path }
+ : {
+ prefix: path.slice(0, slashIndex + 1),
+ leaf: path.slice(slashIndex + 1),
+ };
+}
+
+let measureCanvas: HTMLCanvasElement | null = null;
+
+function compactReviewPath(path: string, container: HTMLElement) {
+ const full = splitReviewPath(path);
+ const width = container.clientWidth;
+ if (width <= 0) return full;
+ const measure = createTextMeasurer(container);
+ if (measure(path) <= width) return full;
+ const parts = path.split('/').filter(Boolean);
+ const leaf = parts.at(-1) ?? path;
+ const fileWidth = measure(leaf);
+ if (parts.length <= 1 || fileWidth + measure('.../') > width) {
+ return { prefix: '', leaf };
+ }
+ let prefix = '.../';
+ for (let dirCount = 1; dirCount < parts.length; dirCount++) {
+ const dirs = parts.slice(parts.length - 1 - dirCount, -1);
+ const candidate = `.../${dirs.join('/')}/`;
+ if (measure(candidate) + fileWidth > width) break;
+ prefix = candidate;
+ }
+ return { prefix, leaf };
+}
+
+function createTextMeasurer(element: HTMLElement) {
+ measureCanvas ??= document.createElement('canvas');
+ const context = measureCanvas.getContext('2d');
+ const style = window.getComputedStyle(element);
+ if (context) {
+ context.font = [
+ style.fontStyle,
+ style.fontVariant,
+ style.fontWeight,
+ style.fontSize,
+ style.fontFamily,
+ ].join(' ');
+ }
+ return (text: string) => context?.measureText(text).width ?? text.length * 8;
+}
+
+function FolderIcon() {
+ return (
+
+ );
+}
+
+function FolderOpenIcon() {
+ return (
+
+ );
+}
+
+function ChevronIcon() {
+ return (
+
+ );
+}
+
+function TreeChevronIcon() {
+ return (
+
+ );
+}
+
+function buildFileTree(
+ changes: readonly TurnOutputFileChange[],
+ workspaceCwd?: string,
+): FileTreeNode {
+ const root: FileTreeNode = { name: '', path: '', children: [] };
+ for (const change of changes) {
+ const parts = displayPath(change.path, workspaceCwd)
+ .split('/')
+ .filter(Boolean);
+ let current = root;
+ for (let index = 0; index < parts.length; index++) {
+ const part = parts[index]!;
+ const path = parts.slice(0, index + 1).join('/');
+ let child = current.children.find((node) => node.name === part);
+ if (!child) {
+ child = { name: part, path, children: [] };
+ current.children.push(child);
+ }
+ if (index === parts.length - 1) child.file = change;
+ current = child;
+ }
+ }
+ sortTree(root);
+ return root;
+}
+
+function sortTree(node: FileTreeNode) {
+ node.children.sort((left, right) => {
+ if (Boolean(left.file) !== Boolean(right.file)) return left.file ? 1 : -1;
+ return left.name.localeCompare(right.name);
+ });
+ for (const child of node.children) sortTree(child);
+}
+
+function fileName(value: string) {
+ const parts = normalizePath(value).split('/').filter(Boolean);
+ return parts.at(-1) ?? value;
+}
+
+function fileExtensionLabel(value: string) {
+ const name = fileName(value);
+ const extension = name.includes('.')
+ ? name.split('.').pop()?.toLowerCase()
+ : '';
+ if (!extension) return 'FILE';
+ const labels: Record = {
+ css: 'CSS',
+ html: 'HTML',
+ js: 'JS',
+ json: 'JSON',
+ jsx: 'JSX',
+ md: 'MD',
+ ts: 'TS',
+ tsx: 'TSX',
+ };
+ return labels[extension] ?? extension.slice(0, 3).toUpperCase();
+}
+
+function ArtifactDetail({
+ artifact,
+ workspaceActions,
+ previewContent,
+}: {
+ artifact: DaemonSessionArtifact;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+}) {
+ const location = getArtifactLocation(artifact);
+ const safeUrl = isSafeHref(artifact.url) ? artifact.url : undefined;
+ const isAutomationSnapshot =
+ artifact.metadata?.['artifactType'] === 'automation_snapshot';
+ const canPreviewWorkspaceFile =
+ artifact.storage === 'workspace' && Boolean(artifact.workspacePath);
+ const canPreviewHtml =
+ canPreviewWorkspaceFile &&
+ artifact.workspacePath &&
+ isHtmlArtifact(artifact);
+
+ if (canPreviewHtml && artifact.workspacePath) {
+ return (
+
+ );
+ }
+
+ if (canPreviewWorkspaceFile && artifact.workspacePath) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {isAutomationSnapshot ? 'Automation Snapshot' : 'Artifact'}
+
+
+
+
+
+
+
+
+
+ {artifact.toolName && (
+
+ )}
+ {artifact.toolCallId && (
+
+ )}
+
+
+
+ {artifact.description && (
+
+
Description
+
{artifact.description}
+
+ )}
+
+ {isAutomationSnapshot && artifact.metadata && (
+
+
Details
+
+ {metadataField(artifact.metadata, 'automationId', 'Automation ID')}
+ {metadataField(artifact.metadata, 'schedule', 'Schedule')}
+ {metadataField(artifact.metadata, 'timezone', 'Timezone')}
+ {metadataField(artifact.metadata, 'status', 'Status')}
+ {metadataField(artifact.metadata, 'nextRunAt', 'Next run')}
+ {metadataField(artifact.metadata, 'prompt', 'Prompt')}
+
+
+ )}
+
+ {(location || safeUrl) && (
+
+
Location
+ {safeUrl ? (
+
+ {safeUrl}
+
+ ) : (
+
{location}
+ )}
+
+ )}
+
+ );
+}
+
+function isHtmlArtifact(artifact: DaemonSessionArtifact) {
+ const path = artifact.workspacePath?.toLowerCase() ?? '';
+ return (
+ artifact.kind === 'html' || path.endsWith('.html') || path.endsWith('.htm')
+ );
+}
+
+function HtmlArtifactPreview({
+ workspacePath,
+ artifactVersion,
+ workspaceActions,
+ previewContent,
+}: {
+ workspacePath: string;
+ artifactVersion?: string;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+}) {
+ const [html, setHtml] = useState(previewContent ?? null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setHtml(previewContent ?? null);
+ setError(null);
+ workspaceActions
+ .readWorkspaceFile(workspacePath)
+ .then((file) => {
+ if (cancelled) return;
+ setHtml(file.content);
+ if (file.truncated) {
+ setError('Preview is truncated because the file is too large.');
+ }
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactVersion, previewContent, workspaceActions, workspacePath]);
+
+ return (
+
+ {html === null ? (
+
Loading preview...
+ ) : (
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+function FileArtifactPreview({
+ workspacePath,
+ artifactVersion,
+ workspaceActions,
+}: {
+ workspacePath: string;
+ artifactVersion?: string;
+ workspaceActions: DaemonWorkspaceActions;
+}) {
+ const hostRef = useRef(null);
+ const [content, setContent] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setContent(null);
+ setError(null);
+ workspaceActions
+ .readWorkspaceFile(workspacePath)
+ .then((file) => {
+ if (cancelled) return;
+ setContent(file.content);
+ if (file.truncated) {
+ setError('File is truncated because it is too large.');
+ }
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactVersion, workspaceActions, workspacePath]);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host || content === null) return;
+ host.replaceChildren();
+ let view: EditorView;
+ try {
+ view = new EditorView({
+ doc: content,
+ extensions: [
+ basicSetup,
+ EditorView.editable.of(false),
+ EditorState.readOnly.of(true),
+ EditorView.lineWrapping,
+ ],
+ parent: host,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ return undefined;
+ }
+ return () => view.destroy();
+ }, [content]);
+
+ return (
+
+ {content === null ? (
+
Loading file...
+ ) : (
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+function Field({ label, value }: { label: string; value?: string }) {
+ if (!value) return null;
+ return (
+ <>
+ {label}
+ {value}
+ >
+ );
+}
+
+function metadataField(
+ metadata: NonNullable,
+ key: string,
+ label: string,
+) {
+ const value = metadata[key];
+ if (value === undefined || value === null || value === '') return null;
+ return ;
+}
diff --git a/packages/web-shell/client/components/artifacts/LineStats.tsx b/packages/web-shell/client/components/artifacts/LineStats.tsx
new file mode 100644
index 00000000000..3ef87743ae0
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/LineStats.tsx
@@ -0,0 +1,41 @@
+import type { TurnOutputFileChange } from './TurnOutputs';
+
+export function LineStats({
+ additions,
+ deletions,
+ className,
+ additionsClassName,
+ deletionsClassName,
+}: {
+ additions: number | undefined;
+ deletions: number | undefined;
+ className: string;
+ additionsClassName: string;
+ deletionsClassName: string;
+}) {
+ if (additions === undefined || deletions === undefined) return null;
+ return (
+
+ +{additions}
+ -{deletions}
+
+ );
+}
+
+export function sumLineStats(changes: readonly TurnOutputFileChange[]) {
+ if (
+ changes.some(
+ (change) =>
+ change.additions === undefined || change.deletions === undefined,
+ )
+ ) {
+ return undefined;
+ }
+ return changes.reduce(
+ (sum, change) => ({
+ additions: sum.additions + (change.additions ?? 0),
+ deletions: sum.deletions + (change.deletions ?? 0),
+ }),
+ { additions: 0, deletions: 0 },
+ );
+}
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.module.css b/packages/web-shell/client/components/artifacts/TurnOutputs.module.css
new file mode 100644
index 00000000000..e6be8cc14dc
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.module.css
@@ -0,0 +1,182 @@
+.root {
+ margin: 8px 0 12px;
+ display: grid;
+ gap: 8px;
+}
+
+.card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--background);
+ overflow: hidden;
+}
+
+.summary {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding: 12px;
+}
+
+.icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--muted-foreground);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.iconSvg {
+ width: 24px;
+ height: 24px;
+ display: block;
+}
+
+.title {
+ min-width: 0;
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.linkButton,
+.reviewButton,
+.showMoreButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+}
+
+.linkButton {
+ margin-top: 2px;
+ margin-left: 8px;
+ padding: 0;
+ color: var(--muted-foreground);
+ font-size: 13px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+}
+
+.card:hover .linkButton,
+.linkButton:focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.linkButton:hover,
+.showMoreButton:hover {
+ color: var(--foreground);
+}
+
+.actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reviewButton {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 4px 10px;
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.reviewButton:hover {
+ background: var(--accent);
+}
+
+.list {
+ display: grid;
+ gap: 0;
+ border-top: 1px solid var(--border);
+ padding: 0;
+}
+
+.fileRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ padding: 7px 14px;
+ text-align: left;
+}
+
+.fileRow:hover {
+ background: var(--accent);
+}
+
+.path {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--foreground);
+ font-size: 13px;
+}
+
+.lineStats {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.additions {
+ color: #16a34a;
+}
+
+.deletions {
+ color: #dc2626;
+}
+
+.showMoreButton {
+ justify-self: start;
+ padding: 7px 14px;
+ color: var(--foreground);
+ font-weight: 400;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.chevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+ color: var(--muted-foreground);
+ transition: transform 120ms ease;
+}
+
+.chevronIconOpen {
+ transform: rotate(180deg);
+}
+
+.artifactInfo {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.artifactMeta {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ white-space: nowrap;
+}
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts b/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts
new file mode 100644
index 00000000000..eef8141f759
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import {
+ getArtifactPreviewContent,
+ type TurnOutputFileChange,
+} from './TurnOutputs';
+
+describe('TurnOutputs helpers', () => {
+ it('uses workspace cwd when matching artifact preview content', () => {
+ const artifact = {
+ id: 'artifact-1',
+ kind: 'html',
+ workspacePath: 'reports/summary.html',
+ } as DaemonSessionArtifact;
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: '/workspace/project/reports/summary.html',
+ status: 'modified',
+ toolCallId: 'tool-1',
+ isArtifact: true,
+ diffs: [
+ {
+ oldText: 'old',
+ newText: 'new',
+ fullContent: true,
+ },
+ ],
+ },
+ ];
+
+ expect(
+ getArtifactPreviewContent(artifact, changes, '/workspace/project'),
+ ).toBe('new');
+ });
+});
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.tsx b/packages/web-shell/client/components/artifacts/TurnOutputs.tsx
new file mode 100644
index 00000000000..36b0ea920b8
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.tsx
@@ -0,0 +1,473 @@
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk';
+import { memo, useState } from 'react';
+import { useI18n } from '../../i18n';
+import { describeCron } from '../dialogs/scheduledTasksSchedule';
+import {
+ artifactKindLabel,
+ formatArtifactSize,
+ isSamePath,
+ stripWorkspacePath,
+} from './artifactUtils';
+import { LineStats, sumLineStats } from './LineStats';
+import styles from './TurnOutputs.module.css';
+
+export interface TurnOutputFileChange {
+ path: string;
+ status: 'created' | 'modified';
+ toolCallId: string;
+ isArtifact: boolean;
+ additions?: number;
+ deletions?: number;
+ diffs: TurnOutputFileDiff[];
+}
+
+export interface TurnOutputFileDiff {
+ oldText: string;
+ newText: string;
+ fileDiff?: string;
+ fullContent?: boolean;
+}
+
+export interface TurnOutputScheduledTask {
+ id: string;
+ toolCallId: string;
+ title: string;
+ cron: string;
+ prompt: string;
+ recurring: boolean;
+ durable: boolean;
+ display?: string;
+}
+
+export type TurnOutputKind = 'file' | 'artifact' | 'scheduled_task';
+
+export const TURN_OUTPUT_KINDS: readonly TurnOutputKind[] = [
+ 'file',
+ 'artifact',
+ 'scheduled_task',
+];
+
+export type TurnOutputOpenRequest =
+ | {
+ id: 'review';
+ kind: 'review';
+ title: string;
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ selectedPath?: string;
+ }
+ | {
+ id: string;
+ kind: 'artifact';
+ title: string;
+ turnId: string;
+ artifactId: string;
+ artifact: DaemonSessionArtifact;
+ workspaceActions?: DaemonWorkspaceActions;
+ previewContent?: string;
+ }
+ | {
+ id: string;
+ kind: 'scheduled_task';
+ title: string;
+ turnId: string;
+ task: TurnOutputScheduledTask;
+ workspaceActions?: DaemonWorkspaceActions;
+ };
+
+interface TurnOutputsProps {
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ artifacts: readonly DaemonSessionArtifact[];
+ scheduledTasks: readonly TurnOutputScheduledTask[];
+ workspaceCwd?: string;
+ onOpenRequest?: (request: TurnOutputOpenRequest) => void;
+ onReviewChanges: (
+ changes: readonly TurnOutputFileChange[],
+ selectedPath?: string,
+ ) => void;
+ onOpenArtifact: (artifactId: string, previewContent?: string) => void;
+ onOpenScheduledTask: (task: TurnOutputScheduledTask) => void;
+}
+
+function TurnOutputsComponent({
+ turnId,
+ changes,
+ artifacts,
+ scheduledTasks,
+ workspaceCwd,
+ onOpenRequest,
+ onReviewChanges,
+ onOpenArtifact,
+ onOpenScheduledTask,
+}: TurnOutputsProps) {
+ const { t } = useI18n();
+ const [showAllChanges, setShowAllChanges] = useState(false);
+ if (
+ changes.length === 0 &&
+ artifacts.length === 0 &&
+ scheduledTasks.length === 0
+ ) {
+ return null;
+ }
+ const visibleChanges = showAllChanges ? changes : changes.slice(0, 3);
+ const remainingChanges = changes.length - 3;
+ const totals = sumLineStats(changes);
+ const openReview = (selectedPath?: string) => {
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: 'review',
+ kind: 'review',
+ title: t('turnOutputs.review'),
+ turnId,
+ changes,
+ ...(selectedPath ? { selectedPath } : {}),
+ });
+ return;
+ }
+ onReviewChanges(changes, selectedPath);
+ };
+ const openArtifact = (artifact: DaemonSessionArtifact) => {
+ const previewContent = getArtifactPreviewContent(
+ artifact,
+ changes,
+ workspaceCwd,
+ );
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: `artifact:${artifact.id}`,
+ kind: 'artifact',
+ title: artifact.title ?? 'Artifact',
+ turnId,
+ artifactId: artifact.id,
+ artifact,
+ ...(previewContent !== undefined ? { previewContent } : {}),
+ });
+ return;
+ }
+ onOpenArtifact(artifact.id, previewContent);
+ };
+ const openScheduledTask = (task: TurnOutputScheduledTask) => {
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: `scheduled-task:${task.toolCallId}`,
+ kind: 'scheduled_task',
+ title: t('scheduledTasks.title'),
+ turnId,
+ task,
+ });
+ return;
+ }
+ onOpenScheduledTask(task);
+ };
+
+ return (
+
+ {changes.length > 0 && (
+
+
+
+
+
+
+
+ {t('turnOutputs.filesEdited', { count: changes.length })}
+
+
+
+
+
+
+
+
+
+
+ {visibleChanges.map((change) => (
+
+ ))}
+ {remainingChanges > 0 && (
+
+ )}
+
+
+ )}
+
+ {artifacts.map((artifact) => (
+
openArtifact(artifact)}
+ />
+ ))}
+
+ {scheduledTasks.map((task) => (
+ openScheduledTask(task)}
+ />
+ ))}
+
+ );
+}
+
+function ArtifactCard({
+ artifact,
+ onOpen,
+}: {
+ artifact: DaemonSessionArtifact;
+ onOpen: () => void;
+}) {
+ const { t } = useI18n();
+ const size = formatArtifactSize(artifact.sizeBytes);
+ return (
+
+
+
+
+
+
+
{artifact.title}
+
+ {[artifactKindLabel(artifact.kind), size]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+
+
+
+ );
+}
+
+function ScheduledTaskCard({
+ task,
+ scheduleLabel,
+ onOpen,
+}: {
+ task: TurnOutputScheduledTask;
+ scheduleLabel: string;
+ onOpen: () => void;
+}) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+
+
+
{task.title}
+
+ {[
+ scheduleLabel,
+ task.recurring
+ ? t('scheduledTasks.repeats')
+ : t('scheduledTasks.runsOnce'),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+
+
+
+ );
+}
+
+function DocumentIcon() {
+ return (
+
+ );
+}
+
+function ClockIcon() {
+ return (
+
+ );
+}
+
+function ChevronIcon({ open }: { open: boolean }) {
+ return (
+
+ );
+}
+
+export const TurnOutputs = memo(TurnOutputsComponent);
+
+export function getArtifactPreviewContent(
+ artifact: DaemonSessionArtifact,
+ changes: readonly TurnOutputFileChange[],
+ workspaceCwd?: string,
+) {
+ if (artifact.kind !== 'html' || !artifact.workspacePath) return undefined;
+ const change = changes.find((item) =>
+ isSamePath(item.path, artifact.workspacePath, workspaceCwd),
+ );
+ if (!change) return undefined;
+ for (let index = change.diffs.length - 1; index >= 0; index--) {
+ const diff = change.diffs[index];
+ if (diff?.fullContent) return diff.newText;
+ }
+ return undefined;
+}
+
+export function displayPath(path: string, workspaceCwd?: string) {
+ return stripWorkspacePath(path, workspaceCwd);
+}
diff --git a/packages/web-shell/client/components/artifacts/artifactUtils.test.ts b/packages/web-shell/client/components/artifacts/artifactUtils.test.ts
new file mode 100644
index 00000000000..d262d06fa34
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/artifactUtils.test.ts
@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { normalizePath, withArtifactPreviewCsp } from './artifactUtils';
+
+describe('artifactUtils', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('resolves parent path segments', () => {
+ expect(normalizePath('src/foo/../bar.ts')).toBe('src/bar.ts');
+ expect(normalizePath('/workspace/app/../app/src/./main.ts')).toBe(
+ '/workspace/app/src/main.ts',
+ );
+ expect(normalizePath('../outside/file.ts')).toBe('../outside/file.ts');
+ });
+
+ it('injects preview CSP and strips unsafe metadata', () => {
+ const output = withArtifactPreviewCsp(`
+
+
+
+
+
+
+
+ Hello
+
+
+ `);
+
+ expect(output).toContain('Content-Security-Policy');
+ expect(output).toContain("default-src 'none'");
+ expect(output).not.toContain('report-uri');
+ expect(output).not.toMatch(/http-equiv=["']?refresh/i);
+ expect(output).not.toMatch(/