diff --git a/docs/design/2026-08-23-vscode-web-shell-cutover.md b/docs/design/2026-08-23-vscode-web-shell-cutover.md new file mode 100644 index 00000000000..7c81879932d --- /dev/null +++ b/docs/design/2026-08-23-vscode-web-shell-cutover.md @@ -0,0 +1,214 @@ +# Complete the VS Code Web Shell cutover + +Status: Draft + +Depends on [#9719](https://github.com/QwenLM/qwen-code/pull/9719). + +## Goal + +Make the VS Code companion use Web Shell for the complete chat experience, not +only the transcript. Web Shell owns the visible chat UI and its interaction +state, and the extension host keeps the VS Code integrations: process +lifecycle, authentication, workspace trust, diff editors, and the contributed +commands. + +This is the first of two follow-up changes. The second change removes the +remaining repository consumers and deletes `packages/webui`. + +## Current state + +PR #9719 replaces the legacy VS Code message timeline with +`WebShellTranscript`, but the companion remains a hybrid UI: + +- 15 production source files in the VS Code companion still import + `@qwen-code/webui`. +- The composer, completion menu, permission drawer, Ask User Question dialog, + session selector, header, onboarding, image preview, model controls, icons, + shared types, and utility functions still come from `packages/webui`. +- 69 production source files in Web Shell import + `@qwen-code/webui/daemon-react-sdk`. +- `@qwen-code/web-shell` declares `@qwen-code/webui` as both a peer dependency + and a development dependency. + +`WebShellWithProviders` expects the daemon HTTP/SSE runtime, while the +extension owned an ACP connection and exchanged messages with the webview +through `postMessage`. + +## Decisions + +### Run the chat on a workspace-scoped daemon + +This work first built a controlled host entry point driven by the ACP bridge +over `postMessage`. That entry point reimplemented, against a second protocol, +state Web Shell already derives from the daemon — transcript, streaming, +permissions, questions, session history — and the reimplementation is what kept +regressing. The chat now runs on `WebShellWithProviders` instead. + +The extension spawns `qwen serve` on a loopback port bound to the workspace, +with `--require-auth` and a per-process token passed through the environment, +and hands the webview its base URL. The webview talks to that daemon directly; +Web Shell's own session, transcript, and permission machinery is the single +implementation. + +The ACP connection remains, but its role narrows to authentication state and +the `/auth` flow. It no longer carries prompts. + +Consequences worth stating plainly: + +- The extension runs two Qwen processes per workspace: the ACP agent for auth + and the daemon for the conversation. +- The daemon is shared with the CLI and the browser Web Shell for that + workspace, so sessions the companion creates carry the `vscode` source type + and its history is scoped to that source. Without it the panel would list + conversations the user started in a terminal. +- A daemon is bound to one workspace at spawn, so a multi-root window respawns + it when the active root changes. +- Turn-lifecycle features that were driven by ACP agent events — the editor tab + status dot and the long-task/attention notifications — no longer fire, and + `/insight` progress no longer reaches the host. Web Shell renders its own + insight cards. Restoring the tab dot and notifications needs the webview to + report turn and permission transitions to the host; that is not in this + change. + +### Use Web Shell's standard entry point + +The companion mounts `WebShellWithProviders` and customizes it through props +rather than through a bespoke embedded component: composer toolbar actions, +host-only slash entries, active-editor context injection, review-diff and +insight-report open handlers, and the session source type. The VS Code chrome +the daemon cannot supply — the view header, session history dropdown, +onboarding, and account dialog — stays in the extension and is themed from VS +Code tokens and localized from the same language signal Web Shell uses. + +The host contract covers these existing capabilities: + +| State supplied by the host | Actions sent to the host | +| ------------------------------------------------ | ------------------------------------------- | +| Active session and session summaries | Submit and cancel a prompt | +| Transcript blocks and streaming state | Create or switch session | +| Pending permission and question requests | Respond to permission or question | +| Models, approval mode, commands, and skills | Change model or approval mode | +| Context usage, authentication, and account state | Request completion and authentication | +| Workspace files and pasted images | Open a file, diff, report, or external link | + +The contract is owned by `@qwen-code/web-shell`; it is not a new workspace +package and it does not introduce another shared `Message[]` model. Transcript +data continues to use the canonical SDK `DaemonTranscriptBlock[]` contract. + +### Make Web Shell self-contained + +The daemon React providers and hooks currently stored under +`packages/webui/src/daemon` belong to the Web Shell runtime integration. They +move under `packages/web-shell` and Web Shell stops importing or declaring +`@qwen-code/webui`. + +If the low-level provider API must remain available to external Web Shell +embedders, it will be exported from a Web Shell subpath. It will not be moved +to a new package. The batteries-included `WebShellWithProviders` entry remains +the preferred daemon integration. + +### Delete replaced VS Code code in the same change + +The cutover is not complete while two implementations remain. When the +controlled Web Shell surface owns a capability, the corresponding companion +component, hook, state branch, compatibility re-export, style import, and test +is removed in the same PR. + +The extension may keep code only when it is a real VS Code host capability, +such as opening files, showing native diffs, reading the active editor, +workspace file search, clipboard integration, or extension lifecycle. + +## Scope + +The PR completes and verifies these user flows: + +- onboarding and authenticated empty states; +- start, cancel, and resume a conversation; +- streaming assistant text and expandable thought; +- all tool-call states and plan rendering; +- Bash/Edit permissions, including every response option; +- Ask User Question, including multi-question answers; +- history opening, session selection, new session, and late-frame isolation; +- composer input, slash commands, file completion, skills, images, and active + editor context; +- approval mode, model selection, thinking mode, context usage, and account + access; +- copy actions, file links, report links, and VS Code diff actions; +- error, cancellation, authentication, and reconnect states. + +The latest user message remains editable. The host maps the selected transcript +turn to a daemon rewind snapshot and rewinds the session before resubmitting. + +## Out of scope + +- Removing the ACP connection entirely; it still owns authentication. +- Restoring the tab status dot, completion notifications, and host-side + `/insight` progress on daemon turn events. +- Migrating desktop-specific navigation or native window chrome. +- Creating `@qwen-code/chat-panel`, another UI package, or a parallel message + model. +- Deleting `packages/webui`; that is the dependent cleanup PR. +- Deleting `packages/desktop/apps/webui`, which is a separately named desktop + application and is not the legacy shared package targeted here. + +## Implementation sequence inside this PR + +1. Relocate the daemon React layer into Web Shell and update its internal + imports, build aliases, public entry points, tests, and README. +2. Spawn a workspace-scoped daemon from the extension host and bootstrap the + webview with its base URL, token, and client id. +3. Mount `WebShellWithProviders` against that daemon and add the props the + companion needs, including its session source type. +4. Remove replaced companion components, hooks, compatibility utilities, + WebUI styles, Tailwind preset usage, package dependency, and bundler + exceptions. +5. Add an import gate proving neither `packages/web-shell` nor + `packages/vscode-ide-companion` depends on `@qwen-code/webui`. + +These are implementation steps within one review unit, not separate PRs. + +## Verification gate + +### Automated + +- Web Shell unit, DOM, typecheck, lint, and library build checks. +- VS Code companion unit, typecheck, lint, and production bundle checks. +- Contract tests for every host state/action mapping. +- Session-switch tests that reject updates belonging to the previous session. +- A repository check that rejects new WebUI imports in Web Shell and VS Code. + +### Real VS Code UI E2E + +Run the built extension in one real VS Code Extension Development Host using a +stable test profile. Launch that host once and reuse it for the full matrix +instead of restarting VS Code per case. The dedicated profile avoids modifying +the user's normal editor process, must dismiss onboarding without requiring +GitHub or Copilot sign-in, and must preserve its extension state between cases. + +Capture screenshots for: + +1. dark and light transcript parity; +2. composer with file, slash-command, skill, and image attachments; +3. permission request before and after a response; +4. Ask User Question before and after submission; +5. history selector and session switch; +6. pending, running, completed, failed, and cancelled tool calls; +7. streaming thought/text and cancellation; +8. model and approval-mode controls; +9. authentication, empty, error, and reconnect states. + +The PR test report must distinguish WebView-only assertions from actions whose +VS Code host or daemon side effect was observed end to end. + +## Completion criteria + +- VS Code mounts Web Shell for the entire chat flow. +- Companion-created sessions are attributable to the `vscode` source and the + panel's history lists only them. +- Web Shell and VS Code contain no production import of `@qwen-code/webui`. +- The Web Shell package has no peer, development, build, or Vite dependency on + `@qwen-code/webui`. +- Replaced companion UI and interaction code is deleted. +- All listed real-host E2E flows have assertions and screenshot evidence. +- Remaining repository references to `packages/webui` are enumerated in the + dependent deletion PR. diff --git a/docs/developers/daemon/18-error-taxonomy.md b/docs/developers/daemon/18-error-taxonomy.md index c4f15b7320a..c8aaa16a3d8 100644 --- a/docs/developers/daemon/18-error-taxonomy.md +++ b/docs/developers/daemon/18-error-taxonomy.md @@ -37,30 +37,30 @@ The `io_error` vs `permission_denied` distinction is deliberate so monitoring pi Typed classes thrown by the bridge / mediator. Most carry an HTTP status via the route handler's switch. -| Class | HTTP | Cause | Remediation | -| ------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SessionNotFoundError` | 404 | sessionId not in `byId` (`code: "session_not_found"`) or the session is closing (`code: "session_closing"`). | For `session_not_found`: re-create or attach; the session may have been reaped. For `session_closing`: wait and retry; a concurrent close is in progress. DELETE routes treat `session_closing` as idempotent success. | -| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon's `boundWorkspace`. | Omit `cwd` (uses bound) or route to a daemon bound to your `cwd`. | -| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions`. | Close stale sessions; bump `--max-sessions`. | -| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` outside `[A-Za-z0-9._:-]{1,128}`. | Sanitize the client id. | -| `InvalidSessionMetadataError` | 400 | `displayName` > 256 chars or contains control chars. | Trim / sanitize. | -| `InvalidSessionScopeError` | 400 | Unknown `sessionScope` value. | Use `'single'` or `'thread'`. | -| `RestoreInProgressError` | 409 | `loadSession`, `resumeSession`, or a caller-supplied id on `POST /session` collides with another registration that already owns the same id. | Wait for the advertised delay and retry the requested restore or spawn; abandoned cleanup carries a budget-derived backoff. | -| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` against an existing file without `force`. | Pass `force: true` or pick another path. | -| `WorkspaceInitPathEscapeError` | 400 | Init path leaves workspace. | Use a path inside `workspaceCwd`. | -| `WorkspaceInitSymlinkError` | 400 | Init path is a symlink. | Address the resolved path. | -| `WorkspaceInitRaceError` | 409 | TOCTOU race on init. | Retry. | -| `McpServerNotFoundError` | 404 | Restart for an unknown server. | Verify server name in `/workspace/mcp`. | -| `McpServerRestartFailedError` | 502 | Restart failed inside ACP child. | Check ACP child logs; may indicate broken MCP server. | -| `InvalidPermissionOptionError` | 400 | Wire vote tried to inject `CANCEL_VOTE_SENTINEL` via `optionId`. | Vote with `{outcome: 'cancelled'}` instead of an `optionId`. | -| `PermissionForbiddenError` | 403 | Policy refused the voter (`designated_mismatch` / `remote_not_allowed`). | Use the originator client id (designated), pre-register voter (consensus), or vote from loopback (local-only). See [`04-permission-mediation.md`](./04-permission-mediation.md). | -| `CancelSentinelCollisionError` | 500 | Agent published `'__cancelled__'` as a legitimate option label. | Agent bug — change the option label to anything other than the sentinel. | -| `PermissionPolicyNotImplementedError` | 500 | Requested policy not built into this daemon. | Update daemon, or change `policy.permissionStrategy`. | -| `BridgeChannelClosedError` | 503 | ACP child channel closed mid-call. | Reconnect / retry; check `session_died` for cause. | -| `BridgeTimeoutError` | 504 | Bridge-level wallclock exceeded. | Retry; investigate underlying slowness. | -| `SessionRestoreTimeoutError` | 504 | ACP session load/resume exceeded its dedicated restore budget. | Retry after the advertised delay; inspect restore stage traces before raising the budget. | -| `BridgeChannelQuarantinedError` | 503 | `reason` is `restore_cleanup_failed`, `restore_settlement_overdue`, `new_session_cleanup_failed`, or `new_session_settlement_overdue`. A settlement-overdue state clears after a late failure settles or a late success completes exact-ID cleanup; inconclusive cleanup transitions to the matching cleanup-failed state. Cleanup-failed states last until the workspace channel drains. The 503 body also carries `retryAfterSeconds`. | Keep using existing sessions and retry after the advertised delay; cleanup-failed states require channel recycle, while settlement-overdue states may recover after settlement and any required cleanup complete. | -| `MissingCliEntryError` | 500 | The `qwen` CLI entry file is missing (defined in `status.ts`, not `bridgeErrors.ts`). | Confirm the CLI install is complete; check that `packages/cli/index.ts` exists. | +| Class | HTTP | Cause | Remediation | +| ------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SessionNotFoundError` | 404 | sessionId not in `byId` (`code: "session_not_found"`) or the session is closing (`code: "session_closing"`). | For `session_not_found`: re-create or attach; the session may have been reaped. For `session_closing`: wait and retry; a concurrent close is in progress. DELETE routes treat `session_closing` as idempotent success. | +| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon's `boundWorkspace`. | Omit `cwd` (uses bound) or route to a daemon bound to your `cwd`. | +| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions`. | Close stale sessions; bump `--max-sessions`. | +| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` outside `[A-Za-z0-9._:-]{1,128}`. | Sanitize the client id. | +| `InvalidSessionMetadataError` | 400 | `displayName` > 256 chars or contains control chars. | Trim / sanitize. | +| `InvalidSessionScopeError` | 400 | Unknown `sessionScope` value. | Use `'single'` or `'thread'`. | +| `RestoreInProgressError` | 409 | `loadSession`, `resumeSession`, or a caller-supplied id on `POST /session` collides with another registration that already owns the same id. | Wait for the advertised delay and retry the requested restore or spawn; abandoned cleanup carries a budget-derived backoff. | +| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` against an existing file without `force`. | Pass `force: true` or pick another path. | +| `WorkspaceInitPathEscapeError` | 400 | Init path leaves workspace. | Use a path inside `workspaceCwd`. | +| `WorkspaceInitSymlinkError` | 400 | Init path is a symlink. | Address the resolved path. | +| `WorkspaceInitRaceError` | 409 | TOCTOU race on init. | Retry. | +| `McpServerNotFoundError` | 404 | Restart for an unknown server. | Verify server name in `/workspace/mcp`. | +| `McpServerRestartFailedError` | 502 | Restart failed inside ACP child. | Check ACP child logs; may indicate broken MCP server. | +| `InvalidPermissionOptionError` | 400 | Wire vote tried to inject `CANCEL_VOTE_SENTINEL` via `optionId`. | Vote with `{outcome: 'cancelled'}` instead of an `optionId`. | +| `PermissionForbiddenError` | 403 | Policy refused the voter (`designated_mismatch` / `remote_not_allowed`). | Use the originator client id (designated), pre-register voter (consensus), or vote from loopback (local-only). See [`04-permission-mediation.md`](./04-permission-mediation.md). | +| `CancelSentinelCollisionError` | 500 | Agent published `'__cancelled__'` as a legitimate option label. | Agent bug — change the option label to anything other than the sentinel. | +| `PermissionPolicyNotImplementedError` | 500 | Requested policy not built into this daemon. | Update daemon, or change `policy.permissionStrategy`. | +| `BridgeChannelClosedError` | 503 | ACP child channel closed mid-call. | Reconnect / retry; check `session_died` for cause. | +| `BridgeTimeoutError` | 504 | Bridge-level wallclock exceeded. | Retry; investigate underlying slowness. | +| `SessionRestoreTimeoutError` | 504 | ACP session load/resume exceeded its dedicated restore budget. | Retry after the advertised delay; inspect restore stage traces before raising the budget. | +| `BridgeChannelQuarantinedError` | 503 | `reason` is `restore_cleanup_failed`, `restore_settlement_overdue`, `new_session_cleanup_failed`, or `new_session_settlement_overdue`. A settlement-overdue state clears after a late failure settles or a late success completes exact-ID cleanup; inconclusive cleanup transitions to the matching cleanup-failed state. Cleanup-failed states last until the workspace channel drains. The 503 body also carries `retryAfterSeconds`. | Keep using existing sessions and retry after the advertised delay; cleanup-failed states require channel recycle, while settlement-overdue states may recover after settlement and any required cleanup complete. | +| `MissingCliEntryError` | 500 | The `qwen` CLI entry file is missing (defined in `status.ts`, not `bridgeErrors.ts`). | Confirm the CLI install is complete; check that `packages/cli/index.ts` exists. | ## Boot-time configuration errors (`packages/cli/src/serve/run-qwen-serve.ts`) diff --git a/eslint.config.js b/eslint.config.js index 76c2057a29e..77c2320af98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -301,6 +301,12 @@ export default tseslint.config( 'prefer-const': ['error', { destructuring: 'all' }], }, }, + { + files: ['packages/web-shell/client/daemon/**/*.{ts,tsx}'], + rules: { + 'no-console': ['error', { allow: ['debug', 'warn', 'error'] }], + }, + }, { files: [ 'packages/web-shell/client/**/*.test.{ts,tsx}', @@ -471,6 +477,26 @@ export default tseslint.config( }, }, + // The VS Code companion renders through @qwen-code/web-shell; the legacy + // @qwen-code/webui surface must not re-enter the extension bundle. + { + files: ['packages/vscode-ide-companion/src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@qwen-code/webui', '@qwen-code/webui/*'], + message: + 'vscode-ide-companion must render through @qwen-code/web-shell; do not re-introduce @qwen-code/webui.', + }, + ], + }, + ], + }, + }, + // ==================== no-console allowlist ==================== // The following files/packages are allowed to use console.* diff --git a/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts b/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts index eaacdd2c3cd..a58ffb5a044 100644 --- a/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts +++ b/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts @@ -28,8 +28,8 @@ let activeDaemon: SpawnedDaemon | undefined; let root: Root | undefined; let dom: JSDOM; let createRoot: typeof import('react-dom/client').createRoot; -let DaemonSessionProvider: typeof import('@qwen-code/webui/daemon-react-sdk').DaemonSessionProvider; -let useTranscriptBlocks: typeof import('@qwen-code/webui/daemon-react-sdk').useTranscriptBlocks; +let DaemonSessionProvider: typeof import('@qwen-code/web-shell/daemon-react-sdk').DaemonSessionProvider; +let useTranscriptBlocks: typeof import('@qwen-code/web-shell/daemon-react-sdk').useTranscriptBlocks; const originalGlobalDescriptors = new Map( ['window', 'document', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'].map( (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const, @@ -58,7 +58,7 @@ beforeAll(async () => { }); ({ createRoot } = await import('react-dom/client')); ({ DaemonSessionProvider, useTranscriptBlocks } = await import( - '@qwen-code/webui/daemon-react-sdk' + '@qwen-code/web-shell/daemon-react-sdk' )); }); diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index f54107a4e5e..7b6ffce1bf9 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -124,10 +124,10 @@ "@qwen-code/acp-bridge/workspacePaths": [ "../packages/acp-bridge/src/workspacePaths.ts" ], - // qwen-serve-webui-live-journal-recovery.test.ts imports this subpath; - // without an entry it resolves through the exports map to dist. - "@qwen-code/webui/daemon-react-sdk": [ - "../packages/webui/src/daemon-react-sdk.ts" + // qwen-serve-webui-live-journal-recovery.test.ts imports Web Shell's + // daemon bindings; map to source so typecheck does not depend on dist. + "@qwen-code/web-shell/daemon-react-sdk": [ + "../packages/web-shell/client/daemon-react-sdk.ts" ], // channel-plugin.test.ts and the plugin-example sources it imports // both import `@qwen-code/channel-base`. Map it to source so the diff --git a/package-lock.json b/package-lock.json index a397be6fd38..b5c72eeddc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4048,9 +4048,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4703,9 +4700,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4723,9 +4717,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4743,9 +4734,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4763,9 +4751,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4783,9 +4768,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4803,9 +4785,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4823,9 +4802,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4843,9 +4819,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5069,9 +5042,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5086,9 +5056,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5103,9 +5070,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5120,9 +5084,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5137,9 +5098,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5154,9 +5112,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5171,9 +5126,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5188,9 +5140,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7172,9 +7121,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7189,9 +7135,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7206,9 +7149,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7223,9 +7163,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7240,9 +7177,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7257,9 +7191,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7274,9 +7205,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7291,9 +7219,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7308,9 +7233,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7325,9 +7247,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7342,9 +7261,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7359,9 +7275,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7376,9 +7289,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -33320,7 +33230,6 @@ "@qwen-code/acp-bridge": "*", "@qwen-code/sdk": "*", "@qwen-code/web-shell": "*", - "@qwen-code/webui": "*", "cors": "^2.8.5", "dotenv": "^17.1.0", "express": "^5.1.0", @@ -33343,13 +33252,10 @@ "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.9.2", - "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, @@ -33420,7 +33326,6 @@ "devDependencies": { "@playwright/test": "^1.57.0", "@qwen-code/sdk": "file:../sdk-typescript", - "@qwen-code/webui": "file:../webui", "@tailwindcss/vite": "^4.3.2", "@types/node": "^22.0.0", "@types/react": "^19.2.0", @@ -33440,7 +33345,6 @@ }, "peerDependencies": { "@qwen-code/sdk": ">=0.1.8", - "@qwen-code/webui": ">=0.0.1", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } diff --git a/packages/core/src/agents/team/teamHelpers.test.ts b/packages/core/src/agents/team/teamHelpers.test.ts index 64ee4680ac9..d26396f1d3e 100644 --- a/packages/core/src/agents/team/teamHelpers.test.ts +++ b/packages/core/src/agents/team/teamHelpers.test.ts @@ -58,7 +58,9 @@ let rmMockOverride: // otherwise the real readFile runs. vi.mock('node:fs/promises', async (importOriginal) => { const original = await importOriginal(); - type ReadFileHook = (...args: Parameters) => unknown; + type ReadFileHook = ( + ...args: Parameters + ) => unknown; let readFileHook: ReadFileHook | undefined; return { ...original, diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 617c6e5f4a2..dbe6df9fe37 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -33,12 +33,15 @@ export function createDaemonToolPreview( if (isRecord(input)) { const nestedInput = input['rawInput'] ?? input['input'] ?? input['args']; if (nestedInput !== undefined && nestedInput !== input) { + const meta = isRecord(input['_meta']) ? input['_meta'] : undefined; const nested = createDaemonToolPreview( nestedInput, { title: opts.title ?? getFirstString(input, ['title']), toolName: - opts.toolName ?? getFirstString(input, ['toolName', 'name']), + opts.toolName ?? + getFirstString(input, ['toolName', 'name']) ?? + (meta ? getFirstString(meta, ['toolName']) : undefined), toolKind: opts.toolKind ?? getFirstString(input, ['kind']), }, depth + 1, @@ -128,6 +131,7 @@ function detectFileDiff( const oldText = getFirstString(input, [ 'oldText', 'old_text', + 'old_string', 'old_str', 'oldString', ]); @@ -143,6 +147,7 @@ function detectFileDiff( const explicitNewText = getFirstString(input, [ 'newText', 'new_text', + 'new_string', 'new_str', 'newString', ]); diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 7016125c20e..8a0eab44c14 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -16561,446 +16561,301 @@ SOFTWARE. ============================================================ -markdown-it@14.2.0 -(https://github.com/markdown-it/markdown-it) +@codemirror/autocomplete@6.20.3 +(git+https://code.haverbeke.berlin/codemirror/autocomplete.git) -Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. +MIT License -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: +Copyright (C) 2018-2021 by Marijn Haverbeke and others -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ============================================================ -argparse@2.0.1 -(https://github.com/nodeca/argparse) +@codemirror/language@6.12.3 +(git+https://github.com/codemirror/language.git) -A. HISTORY OF THE SOFTWARE -========================== +MIT License -Python was created in the early 1990s by Guido van Rossum at Stichting -Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands -as a successor of a language called ABC. Guido remains Python's -principal author, although it includes many contributions from others. +Copyright (C) 2018-2021 by Marijn Haverbeke and others -In 1995, Guido continued his work on Python at the Corporation for -National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) -in Reston, Virginia where he released several versions of the -software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -In May 2000, Guido and the Python core development team moved to -BeOpen.com to form the BeOpen PythonLabs team. In October of the same -year, the PythonLabs team moved to Digital Creations, which became -Zope Corporation. In 2001, the Python Software Foundation (PSF, see -https://www.python.org/psf/) was formed, a non-profit organization -created specifically to own Python-related Intellectual Property. -Zope Corporation was a sponsoring member of the PSF. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -All Python releases are Open Source (see http://www.opensource.org for -the Open Source Definition). Historically, most, but not all, Python -releases have also been GPL-compatible; the table below summarizes -the various releases. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. - Release Derived Year Owner GPL- - from compatible? (1) - 0.9.0 thru 1.2 1991-1995 CWI yes - 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes - 1.6 1.5.2 2000 CNRI no - 2.0 1.6 2000 BeOpen.com no - 1.6.1 1.6 2001 CNRI yes (2) - 2.1 2.0+1.6.1 2001 PSF no - 2.0.1 2.0+1.6.1 2001 PSF yes - 2.1.1 2.1+2.0.1 2001 PSF yes - 2.1.2 2.1.1 2002 PSF yes - 2.1.3 2.1.2 2002 PSF yes - 2.2 and above 2.1.1 2001-now PSF yes +============================================================ +@codemirror/state@6.6.0 +(git+https://github.com/codemirror/state.git) -Footnotes: +MIT License -(1) GPL-compatible doesn't mean that we're distributing Python under - the GPL. All Python licenses, unlike the GPL, let you distribute - a modified version without making your changes open source. The - GPL-compatible licenses make it possible to combine Python with - other software that is released under the GPL; the others don't. +Copyright (C) 2018-2021 by Marijn Haverbeke and others -(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, - because its license has a choice of law clause. According to - CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 - is "not incompatible" with the GPL. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Thanks to the many outside volunteers who have worked under Guido's -direction to make these releases possible. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON -=============================================================== -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- +============================================================ +@marijn/find-cluster-break@1.0.2 +(git+https://github.com/marijnh/find-cluster-break.git) -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. +MIT License -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. +Copyright (C) 2024 by Marijn Haverbeke -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. +============================================================ +@codemirror/view@6.43.1 +(git+https://code.haverbeke.berlin/codemirror/view.git) -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. +MIT License +Copyright (C) 2018-2021 by Marijn Haverbeke and others -BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 -------------------------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an -office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the -Individual or Organization ("Licensee") accessing and otherwise using -this software in source or binary form and its associated -documentation ("the Software"). +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -2. Subject to the terms and conditions of this BeOpen Python License -Agreement, BeOpen hereby grants Licensee a non-exclusive, -royalty-free, world-wide license to reproduce, analyze, test, perform -and/or display publicly, prepare derivative works, distribute, and -otherwise use the Software alone or in any derivative version, -provided, however, that the BeOpen Python License is retained in the -Software, alone or in any derivative version prepared by Licensee. -3. BeOpen is making the Software available to Licensee on an "AS IS" -basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. +============================================================ +crelt@1.0.6 +(git+https://github.com/marijnh/crelt.git) -4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE -SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS -AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY -DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +Copyright (C) 2020 by Marijn Haverbeke -5. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -6. This License Agreement shall be governed by and interpreted in all -respects by the law of the State of California, excluding conflict of -law provisions. Nothing in this License Agreement shall be deemed to -create any relationship of agency, partnership, or joint venture -between BeOpen and Licensee. This License Agreement does not grant -permission to use BeOpen trademarks or trade names in a trademark -sense to endorse or promote products or services of Licensee, or any -third party. As an exception, the "BeOpen Python" logos available at -http://www.pythonlabs.com/logos.html may be used according to the -permissions granted on that web page. - -7. By copying, installing or otherwise using the software, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - - -CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 ---------------------------------------- - -1. This LICENSE AGREEMENT is between the Corporation for National -Research Initiatives, having an office at 1895 Preston White Drive, -Reston, VA 20191 ("CNRI"), and the Individual or Organization -("Licensee") accessing and otherwise using Python 1.6.1 software in -source or binary form and its associated documentation. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -2. Subject to the terms and conditions of this License Agreement, CNRI -hereby grants Licensee a nonexclusive, royalty-free, world-wide -license to reproduce, analyze, test, perform and/or display publicly, -prepare derivative works, distribute, and otherwise use Python 1.6.1 -alone or in any derivative version, provided, however, that CNRI's -License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) -1995-2001 Corporation for National Research Initiatives; All Rights -Reserved" are retained in Python 1.6.1 alone or in any derivative -version prepared by Licensee. Alternately, in lieu of CNRI's License -Agreement, Licensee may substitute the following text (omitting the -quotes): "Python 1.6.1 is made available subject to the terms and -conditions in CNRI's License Agreement. This Agreement together with -Python 1.6.1 may be located on the Internet using the following -unique, persistent identifier (known as a handle): 1895.22/1013. This -Agreement may also be obtained from a proxy server on the Internet -using the following URL: http://hdl.handle.net/1895.22/1013". +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python 1.6.1 or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python 1.6.1. -4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" -basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. +============================================================ +style-mod@4.1.3 +(git+https://github.com/marijnh/style-mod.git) -5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +Copyright (C) 2018 by Marijn Haverbeke and others -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -7. This License Agreement shall be governed by the federal -intellectual property law of the United States, including without -limitation the federal copyright law, and, to the extent such -U.S. federal law does not apply, by the law of the Commonwealth of -Virginia, excluding Virginia's conflict of law provisions. -Notwithstanding the foregoing, with regard to derivative works based -on Python 1.6.1 that incorporate non-separable material that was -previously distributed under the GNU General Public License (GPL), the -law of the Commonwealth of Virginia shall govern this License -Agreement only as to issues arising under or with respect to -Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this -License Agreement shall be deemed to create any relationship of -agency, partnership, or joint venture between CNRI and Licensee. This -License Agreement does not grant permission to use CNRI trademarks or -trade name in a trademark sense to endorse or promote products or -services of Licensee, or any third party. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -8. By clicking on the "ACCEPT" button where indicated, or by copying, -installing or otherwise using Python 1.6.1, Licensee agrees to be -bound by the terms and conditions of this License Agreement. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. - ACCEPT +============================================================ +w3c-keyname@2.2.8 +(git+https://github.com/marijnh/w3c-keyname.git) -CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 --------------------------------------------------- +Copyright (C) 2016 by Marijn Haverbeke and others -Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, -The Netherlands. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Permission to use, copy, modify, and distribute this software and its -documentation for any purpose and without fee is hereby granted, -provided that the above copyright notice appear in all copies and that -both that copyright notice and this permission notice appear in -supporting documentation, and that the name of Stichting Mathematisch -Centrum or CWI not be used in advertising or publicity pertaining to -distribution of the software without specific, written prior -permission. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO -THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE -FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT -OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ============================================================ -entities@4.5.0 -(git://github.com/fb55/entities.git) +@lezer/common@1.5.2 +(https://github.com/lezer-parser/common.git) -Copyright (c) Felix Böhm -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +Copyright (C) 2018 by Marijn Haverbeke and others -Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, -EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ============================================================ -linkify-it@5.0.2 -(https://github.com/markdown-it/linkify-it) +@lezer/highlight@1.2.3 +(https://github.com/lezer-parser/highlight.git) -Copyright (c) 2015 Vitaly Puzrin. +MIT License -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: +Copyright (C) 2018 by Marijn Haverbeke and others -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ============================================================ -uc.micro@2.1.0 -(https://github.com/markdown-it/uc.micro) +@lezer/lr@1.4.10 +(git+https://code.haverbeke.berlin/lezer/lr.git) -Copyright Mathias Bynens +MIT License -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Copyright (C) 2018 by Marijn Haverbeke and others -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ============================================================ -mdurl@2.0.0 -(https://github.com/markdown-it/mdurl) +@codemirror/commands@6.10.3 +(git+https://github.com/codemirror/commands.git) -Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - --------------------------------------------------------------------------------- - -.parse() is based on Joyent's node.js `url` code: - -Copyright Joyent, Inc. and other Node contributors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. - - -============================================================ -punycode.js@2.3.1 -(https://github.com/mathiasbynens/punycode.js.git) - -Copyright Mathias Bynens - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -@codemirror/autocomplete@6.20.3 -(git+https://code.haverbeke.berlin/codemirror/autocomplete.git) - -MIT License +MIT License Copyright (C) 2018-2021 by Marijn Haverbeke and others @@ -17024,12 +16879,12 @@ THE SOFTWARE. ============================================================ -@codemirror/language@6.12.3 -(git+https://github.com/codemirror/language.git) +@codemirror/merge@6.12.2 +(git+https://code.haverbeke.berlin/codemirror/merge.git) MIT License -Copyright (C) 2018-2021 by Marijn Haverbeke and others +Copyright (C) 2018-2022 by Marijn Haverbeke and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17051,12 +16906,12 @@ THE SOFTWARE. ============================================================ -@codemirror/state@6.6.0 -(git+https://github.com/codemirror/state.git) +@datafe-open/markdown-chart@0.1.12 +(https://github.com/datafe/markdown-chart.git) MIT License -Copyright (C) 2018-2021 by Marijn Haverbeke and others +Copyright (c) 2026 DataFE contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17065,25 +16920,25 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -@marijn/find-cluster-break@1.0.2 -(git+https://github.com/marijnh/find-cluster-break.git) +@datafe-open/markdown-chart-echarts@0.1.12 +(https://github.com/datafe/markdown-chart.git) MIT License -Copyright (C) 2024 by Marijn Haverbeke +Copyright (c) 2026 DataFE contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17092,50 +16947,51 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -@codemirror/view@6.43.1 -(git+https://code.haverbeke.berlin/codemirror/view.git) +papaparse@5.5.4 +(git+https://github.com/mholt/PapaParse.git) -MIT License +The MIT License (MIT) -Copyright (C) 2018-2021 by Marijn Haverbeke and others +Copyright (c) 2015 Matthew Holt -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -crelt@1.0.6 -(git+https://github.com/marijnh/crelt.git) +@datafe-open/markdown-chart-react@0.1.12 +(https://github.com/datafe/markdown-chart.git) -Copyright (C) 2020 by Marijn Haverbeke +MIT License + +Copyright (c) 2026 DataFE contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17144,23 +17000,25 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -style-mod@4.1.3 -(git+https://github.com/marijnh/style-mod.git) +react-markdown@10.1.0 +(https://github.com/remarkjs/react-markdown) -Copyright (C) 2018 by Marijn Haverbeke and others +The MIT License (MIT) + +Copyright (c) Espen Hovlandsdal Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17169,131 +17027,134 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -w3c-keyname@2.2.8 -(git+https://github.com/marijnh/w3c-keyname.git) +@types/hast@3.0.4 +(https://github.com/DefinitelyTyped/DefinitelyTyped.git) -Copyright (C) 2016 by Marijn Haverbeke and others + MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Copyright (c) Microsoft Corporation. -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE ============================================================ -@lezer/common@1.5.2 -(https://github.com/lezer-parser/common.git) +@types/unist@3.0.3 +(https://github.com/DefinitelyTyped/DefinitelyTyped.git) -MIT License + MIT License -Copyright (C) 2018 by Marijn Haverbeke and others + Copyright (c) Microsoft Corporation. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE ============================================================ -@lezer/highlight@1.2.3 -(https://github.com/lezer-parser/highlight.git) +@types/mdast@4.0.4 +(https://github.com/DefinitelyTyped/DefinitelyTyped.git) -MIT License + MIT License -Copyright (C) 2018 by Marijn Haverbeke and others + Copyright (c) Microsoft Corporation. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE ============================================================ -@lezer/lr@1.4.10 -(git+https://code.haverbeke.berlin/lezer/lr.git) +devlop@1.1.0 +(https://github.com/wooorm/devlop) -MIT License +(The MIT License) -Copyright (C) 2018 by Marijn Haverbeke and others +Copyright (c) 2023 Titus Wormer -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@codemirror/commands@6.10.3 -(git+https://github.com/codemirror/commands.git) +dequal@2.0.3 +(https://github.com/lukeed/dequal) -MIT License +The MIT License (MIT) -Copyright (C) 2018-2021 by Marijn Haverbeke and others +Copyright (c) Luke Edwards (lukeed.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17315,222 +17176,174 @@ THE SOFTWARE. ============================================================ -@codemirror/merge@6.12.2 -(git+https://code.haverbeke.berlin/codemirror/merge.git) +hast-util-to-jsx-runtime@2.3.6 +(https://github.com/syntax-tree/hast-util-to-jsx-runtime) -MIT License +(The MIT License) -Copyright (C) 2018-2022 by Marijn Haverbeke and others +Copyright (c) Titus Wormer -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@datafe-open/markdown-chart@0.1.12 -(https://github.com/datafe/markdown-chart.git) +@types/estree@1.0.9 +(https://github.com/DefinitelyTyped/DefinitelyTyped.git) -MIT License + MIT License -Copyright (c) 2026 DataFE contributors + Copyright (c) Microsoft Corporation. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE ============================================================ -@datafe-open/markdown-chart-echarts@0.1.12 -(https://github.com/datafe/markdown-chart.git) +comma-separated-tokens@2.0.3 +(https://github.com/wooorm/comma-separated-tokens) -MIT License +(The MIT License) -Copyright (c) 2026 DataFE contributors +Copyright (c) 2016 Titus Wormer -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -papaparse@5.5.4 -(git+https://github.com/mholt/PapaParse.git) +estree-util-is-identifier-name@3.0.0 +(https://github.com/syntax-tree/estree-util-is-identifier-name) -The MIT License (MIT) +(The MIT License) -Copyright (c) 2015 Matthew Holt +Copyright (c) 2020 Titus Wormer -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@datafe-open/markdown-chart-react@0.1.12 -(https://github.com/datafe/markdown-chart.git) +hast-util-whitespace@3.0.0 +(https://github.com/syntax-tree/hast-util-whitespace) -MIT License +(The MIT License) -Copyright (c) 2026 DataFE contributors +Copyright (c) 2016 Titus Wormer -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -============================================================ -react-markdown@10.1.0 -(https://github.com/remarkjs/react-markdown) - -The MIT License (MIT) - -Copyright (c) Espen Hovlandsdal - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -============================================================ -@types/hast@3.0.4 -(https://github.com/DefinitelyTyped/DefinitelyTyped.git) - - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@types/unist@3.0.3 -(https://github.com/DefinitelyTyped/DefinitelyTyped.git) +mdast-util-mdx-expression@2.0.1 +(https://github.com/syntax-tree/mdast-util-mdx-expression) - MIT License +(The MIT License) - Copyright (c) Microsoft Corporation. +Copyright (c) 2020 Titus Wormer - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@types/mdast@4.0.4 +@types/estree-jsx@1.0.5 (https://github.com/DefinitelyTyped/DefinitelyTyped.git) MIT License @@ -17557,12 +17370,12 @@ SOFTWARE. ============================================================ -devlop@1.1.0 -(https://github.com/wooorm/devlop) +mdast-util-from-markdown@2.0.3 +(https://github.com/syntax-tree/mdast-util-from-markdown) (The MIT License) -Copyright (c) 2023 Titus Wormer +Copyright (c) Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17585,35 +17398,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -dequal@2.0.3 -(https://github.com/lukeed/dequal) - -The MIT License (MIT) - -Copyright (c) Luke Edwards (lukeed.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -============================================================ -hast-util-to-jsx-runtime@2.3.6 -(https://github.com/syntax-tree/hast-util-to-jsx-runtime) +decode-named-character-reference@1.3.0 +(https://github.com/wooorm/decode-named-character-reference) (The MIT License) @@ -17640,39 +17426,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@types/estree@1.0.9 -(https://github.com/DefinitelyTyped/DefinitelyTyped.git) - - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - -============================================================ -comma-separated-tokens@2.0.3 -(https://github.com/wooorm/comma-separated-tokens) +character-entities@2.0.2 +(https://github.com/wooorm/character-entities) (The MIT License) -Copyright (c) 2016 Titus Wormer +Copyright (c) 2015 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17695,12 +17454,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -estree-util-is-identifier-name@3.0.0 -(https://github.com/syntax-tree/estree-util-is-identifier-name) +mdast-util-to-string@4.0.0 +(https://github.com/syntax-tree/mdast-util-to-string) (The MIT License) -Copyright (c) 2020 Titus Wormer +Copyright (c) 2015 Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17723,12 +17482,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -hast-util-whitespace@3.0.0 -(https://github.com/syntax-tree/hast-util-whitespace) +micromark@4.0.2 +(https://github.com/micromark/micromark/tree/main/packages/micromark) (The MIT License) -Copyright (c) 2016 Titus Wormer +Copyright (c) Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17751,35 +17510,34 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -mdast-util-mdx-expression@2.0.1 -(https://github.com/syntax-tree/mdast-util-mdx-expression) +@types/debug@4.1.13 +(https://github.com/DefinitelyTyped/DefinitelyTyped.git) -(The MIT License) + MIT License -Copyright (c) 2020 Titus Wormer + Copyright (c) Microsoft Corporation. -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE ============================================================ -@types/estree-jsx@1.0.5 +@types/ms@2.1.0 (https://github.com/DefinitelyTyped/DefinitelyTyped.git) MIT License @@ -17806,8 +17564,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -mdast-util-from-markdown@2.0.3 -(https://github.com/syntax-tree/mdast-util-from-markdown) +micromark-core-commonmark@2.0.3 +(https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) (The MIT License) @@ -17834,8 +17592,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -decode-named-character-reference@1.3.0 -(https://github.com/wooorm/decode-named-character-reference) +micromark-factory-destination@2.0.1 +(https://github.com/micromark/micromark/tree/main/packages/micromark-factory-destination) (The MIT License) @@ -17862,12 +17620,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -character-entities@2.0.2 -(https://github.com/wooorm/character-entities) +micromark-util-character@2.1.1 +(https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) (The MIT License) -Copyright (c) 2015 Titus Wormer +Copyright (c) Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17890,12 +17648,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -mdast-util-to-string@4.0.0 -(https://github.com/syntax-tree/mdast-util-to-string) +micromark-util-symbol@2.0.1 +(https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) (The MIT License) -Copyright (c) 2015 Titus Wormer +Copyright (c) Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -17918,202 +17676,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -micromark@4.0.2 -(https://github.com/micromark/micromark/tree/main/packages/micromark) - -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -@types/debug@4.1.13 -(https://github.com/DefinitelyTyped/DefinitelyTyped.git) - - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - -============================================================ -@types/ms@2.1.0 -(https://github.com/DefinitelyTyped/DefinitelyTyped.git) - - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - -============================================================ -micromark-core-commonmark@2.0.3 -(https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) - -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -micromark-factory-destination@2.0.1 -(https://github.com/micromark/micromark/tree/main/packages/micromark-factory-destination) - -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -micromark-util-character@2.1.1 -(https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) - -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -micromark-util-symbol@2.0.1 -(https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) - -(The MIT License) - -Copyright (c) Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -micromark-util-types@2.0.2 -(https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) +micromark-util-types@2.0.2 +(https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) (The MIT License) @@ -27310,100 +26874,557 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -regex-recursion@5.1.1 -(git+https://github.com/slevithan/regex-recursion.git) +============================================================ +regex-recursion@5.1.1 +(git+https://github.com/slevithan/regex-recursion.git) + +MIT License + +Copyright (c) 2024 Steven Levithan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +@shikijs/engine-oniguruma@1.29.2 +(git+https://github.com/shikijs/shiki.git) + +MIT License + +Copyright (c) 2021 Pine Wu +Copyright (c) 2023 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +hast-util-to-html@9.0.5 +(https://github.com/syntax-tree/hast-util-to-html) + +(The MIT License) + +Copyright (c) Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +html-void-elements@3.0.0 +(https://github.com/wooorm/html-void-elements) + +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +@shikijs/langs@1.29.2 +(git+https://github.com/shikijs/shiki.git) + +MIT License + +Copyright (c) 2021 Pine Wu +Copyright (c) 2023 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +@shikijs/themes@1.29.2 +(git+https://github.com/shikijs/shiki.git) + +MIT License + +Copyright (c) 2021 Pine Wu +Copyright (c) 2023 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +tailwind-merge@3.6.0 +(https://github.com/dcastil/tailwind-merge.git) + +MIT License + +Copyright (c) 2021 Dany Castillo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +vaul@1.1.2 +(https://github.com/emilkowalski/vaul.git) + +MIT License + +Copyright (c) 2023 Emil Kowalski + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +markdown-it@14.2.0 +(https://github.com/markdown-it/markdown-it) + +Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +argparse@2.0.1 +(https://github.com/nodeca/argparse) + +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT -MIT License -Copyright (c) 2024 Steven Levithan +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ============================================================ -@shikijs/engine-oniguruma@1.29.2 -(git+https://github.com/shikijs/shiki.git) +entities@4.5.0 +(git://github.com/fb55/entities.git) -MIT License +Copyright (c) Felix Böhm +All rights reserved. -Copyright (c) 2021 Pine Wu -Copyright (c) 2023 Anthony Fu +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ============================================================ -hast-util-to-html@9.0.5 -(https://github.com/syntax-tree/hast-util-to-html) - -(The MIT License) +linkify-it@5.0.2 +(https://github.com/markdown-it/linkify-it) -Copyright (c) Titus Wormer +Copyright (c) 2015 Vitaly Puzrin. -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. ============================================================ -html-void-elements@3.0.0 -(https://github.com/wooorm/html-void-elements) - -(The MIT License) +uc.micro@2.1.0 +(https://github.com/markdown-it/uc.micro) -Copyright (c) 2016 Titus Wormer +Copyright Mathias Bynens Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including +"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to @@ -27412,111 +27433,90 @@ the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -@shikijs/langs@1.29.2 -(git+https://github.com/shikijs/shiki.git) - -MIT License - -Copyright (c) 2021 Pine Wu -Copyright (c) 2023 Anthony Fu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -@shikijs/themes@1.29.2 -(git+https://github.com/shikijs/shiki.git) - -MIT License - -Copyright (c) 2021 Pine Wu -Copyright (c) 2023 Anthony Fu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +mdurl@2.0.0 +(https://github.com/markdown-it/mdurl) -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -============================================================ -tailwind-merge@3.6.0 -(https://github.com/dcastil/tailwind-merge.git) +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. -MIT License +-------------------------------------------------------------------------------- -Copyright (c) 2021 Dany Castillo +.parse() is based on Joyent's node.js `url` code: +Copyright Joyent, Inc. and other Node contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. ============================================================ -vaul@1.1.2 -(https://github.com/emilkowalski/vaul.git) - -MIT License +punycode.js@2.3.1 +(https://github.com/mathiasbynens/punycode.js.git) -Copyright (c) 2023 Emil Kowalski +Copyright Mathias Bynens -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 9ab72e20813..b42e4120479 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -221,16 +221,6 @@ async function main() { outdir: 'dist', entryNames: 'webview', chunkNames: 'chunks/[name]-[hash]', - // @qwen-code/qwen-code-core is a peer dependency of @qwen-code/webui. - // Since @qwen-code/webui marks it as external in its own Vite build, the - // browser bundle must also mark it external to avoid bundling Node.js-only - // modules (undici, @grpc/grpc-js, fs, stream, etc.) into the webview. - // The wildcard ensures deep sub-path imports (e.g. - // '@qwen-code/qwen-code-core/src/core/tokenLimits.js') are also excluded; - // without it esbuild only matches the bare package name and attempts to - // bundle the sub-path, which triggers "Dynamic require is not supported" - // at runtime in the browser. - external: ['@qwen-code/qwen-code-core', '@qwen-code/qwen-code-core/*'], logLevel: 'silent', plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin], jsx: 'automatic', // Use new JSX transform (React 17+) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b6312191d44..4ae9e8af3bf 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -278,13 +278,10 @@ "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.9.2", - "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, @@ -292,7 +289,6 @@ "@agentclientprotocol/sdk": "^0.14.1", "@qwen-code/acp-bridge": "*", "@qwen-code/sdk": "*", - "@qwen-code/webui": "*", "@qwen-code/web-shell": "*", "@modelcontextprotocol/sdk": "^1.30.0", "cors": "^2.8.5", diff --git a/packages/vscode-ide-companion/postcss.config.js b/packages/vscode-ide-companion/postcss.config.js deleted file mode 100644 index 49f4aea7ae9..00000000000 --- a/packages/vscode-ide-companion/postcss.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable no-undef */ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/vscode-ide-companion/scripts/prepackage.js b/packages/vscode-ide-companion/scripts/prepackage.js index 60ba42929bf..ef79fd34961 100644 --- a/packages/vscode-ide-companion/scripts/prepackage.js +++ b/packages/vscode-ide-companion/scripts/prepackage.js @@ -189,10 +189,10 @@ function main() { console.log('[prepackage] Preparing root dist/ package metadata...'); run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot }); - console.log('[prepackage] Preparing webui dist/ package metadata...'); + console.log('[prepackage] Preparing web-shell dist/ package metadata...'); run( npm, - ['--prefix', path.join(repoRoot, 'packages', 'webui'), 'run', 'build'], + ['--prefix', path.join(repoRoot, 'packages', 'web-shell'), 'run', 'build'], { cwd: repoRoot }, ); diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 34a3881daf9..e46c5f071b1 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { authCommand, + closeDiffCommand, focusChatCommand, openNewChatTabCommand, registerNewCommands, @@ -175,6 +176,49 @@ describe('registerNewCommands', () => { ); }); + it('closeDiff resolves relative paths against the workspace', async () => { + workspaceMock.workspaceFolders = [ + { uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 }, + ]; + const closeDiff = vi.fn().mockResolvedValue(undefined); + + registerNewCommands( + context as never, + log, + { showDiff: vi.fn(), closeDiff } as never, + () => [], + vi.fn() as never, + ); + + await getRegisteredHandler(closeDiffCommand)('src/foo.ts'); + + expect(joinPath).toHaveBeenCalledWith( + { fsPath: '/workspace' }, + 'src/foo.ts', + ); + expect(closeDiff).toHaveBeenCalledWith('/workspace/src/foo.ts', true); + }); + + it('closeDiff keeps absolute paths unchanged', async () => { + workspaceMock.workspaceFolders = [ + { uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 }, + ]; + const closeDiff = vi.fn().mockResolvedValue(undefined); + + registerNewCommands( + context as never, + log, + { showDiff: vi.fn(), closeDiff } as never, + () => [], + vi.fn() as never, + ); + + await getRegisteredHandler(closeDiffCommand)('/workspace/src/foo.ts'); + + expect(joinPath).not.toHaveBeenCalled(); + expect(closeDiff).toHaveBeenCalledWith('/workspace/src/foo.ts', true); + }); + it('showDiff keeps UNC paths absolute', async () => { workspaceMock.workspaceFolders = [ { uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 }, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 5f4f376e939..09b6260d563 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -15,6 +15,7 @@ type Logger = (message: string) => void; export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; +export const closeDiffCommand = 'qwenCode.closeDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; export const authCommand = 'qwen-code.auth'; @@ -22,6 +23,22 @@ export const focusChatCommand = 'qwen-code.focusChat'; export const newConversationCommand = 'qwen-code.newConversation'; export const showLogsCommand = 'qwen-code.showLogs'; +/** + * DiffManager keys entries by the normalized absolute path it received from + * showDiff, so a closeDiff that arrives with the same workspace-relative + * path the daemon sent would never match unless it is resolved the same way. + */ +function resolveWorkspaceRelativePath(filePath: string): string { + if (!shouldResolveAgainstWorkspace(filePath)) { + return filePath; + } + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return filePath; + } + return vscode.Uri.joinPath(workspaceFolder.uri, filePath).fsPath; +} + /** * Register all Qwen Code chat-related commands. * @@ -63,16 +80,7 @@ export function registerNewCommands( showDiffCommand, async (args: { path: string; oldText: string; newText: string }) => { try { - let absolutePath = args.path; - if (shouldResolveAgainstWorkspace(args.path)) { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder) { - absolutePath = vscode.Uri.joinPath( - workspaceFolder.uri, - args.path, - ).fsPath; - } - } + const absolutePath = resolveWorkspaceRelativePath(args.path); log(`[Command] Showing diff for ${absolutePath}`); await diffManager.showDiff(absolutePath, args.oldText, args.newText); } catch (error) { @@ -84,6 +92,14 @@ export function registerNewCommands( ), ); + disposables.push( + vscode.commands.registerCommand( + closeDiffCommand, + async (filePath: string) => + diffManager.closeDiff(resolveWorkspaceRelativePath(filePath), true), + ), + ); + // Open New Chat Tab: always create a new editor tab disposables.push( vscode.commands.registerCommand( diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 547db64f9f2..1d65d6de2ee 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -476,7 +476,9 @@ export class AcpConnection { throw new Error('No active ACP session'); } const promptBlocks = - typeof prompt === 'string' ? [{ type: 'text', text: prompt }] : prompt; + typeof prompt === 'string' + ? [{ type: 'text' as const, text: prompt }] + : prompt; const response: PromptResponse = await conn.prompt({ sessionId: this.sessionId, prompt: promptBlocks, diff --git a/packages/vscode-ide-companion/src/services/qwenDaemonProcess.test.ts b/packages/vscode-ide-companion/src/services/qwenDaemonProcess.test.ts new file mode 100644 index 00000000000..5be54a6dd4c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenDaemonProcess.test.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChildProcess } from 'node:child_process'; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ spawn: spawnMock })); + +import { QwenDaemonProcess } from './qwenDaemonProcess.js'; + +type Listener = (...args: unknown[]) => void; + +interface FakeChild { + process: ChildProcess; + kill: ReturnType; + emitStdout: (text: string) => void; + emitExit: (code: number, signal: string | null) => void; + listenerCount: (key: string) => number; +} + +function createFakeChild(): FakeChild { + const listeners = new Map(); + const on = (key: string, callback: Listener) => { + const list = listeners.get(key) ?? []; + list.push(callback); + listeners.set(key, list); + }; + const removeListener = (key: string, callback: Listener) => { + const list = listeners.get(key); + if (!list) return; + listeners.set( + key, + list.filter((entry) => entry !== callback), + ); + }; + const kill = vi.fn(); + return { + kill, + process: { + stdout: { + on: (event: string, cb: Listener) => on(`stdout:${event}`, cb), + removeListener: (event: string, cb: Listener) => + removeListener(`stdout:${event}`, cb), + }, + stderr: { + on: (event: string, cb: Listener) => on(`stderr:${event}`, cb), + removeListener: (event: string, cb: Listener) => + removeListener(`stderr:${event}`, cb), + }, + once: (event: string, cb: Listener) => on(event, cb), + kill, + exitCode: null, + } as unknown as ChildProcess, + emitStdout(text: string) { + for (const callback of [...(listeners.get('stdout:data') ?? [])]) { + callback(Buffer.from(text)); + } + }, + emitExit(code: number, signal: string | null) { + for (const callback of [...(listeners.get('exit') ?? [])]) { + callback(code, signal); + } + }, + listenerCount(key: string) { + return listeners.get(key)?.length ?? 0; + }, + }; +} + +async function settle( + daemon: QwenDaemonProcess, + child: FakeChild, + workspace: string, + port: number, +): Promise { + const start = daemon.start('/cli.js', workspace); + child.emitStdout(`qwen serve listening on http://127.0.0.1:${port}\n`); + await start; +} + +describe('QwenDaemonProcess exit notification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not report the exit of a child superseded by a workspace switch', async () => { + const childA = createFakeChild(); + const childB = createFakeChild(); + spawnMock + .mockReturnValueOnce(childA.process) + .mockReturnValueOnce(childB.process); + + const daemon = new QwenDaemonProcess(); + const onExit = vi.fn(); + daemon.addExitListener(onExit); + + await settle(daemon, childA, '/workspace-a', 4101); + + // A multi-root window opening a chat against another root respawns the + // daemon and kills the first child; that child's later exit is not a + // crash of the live daemon. + const startB = daemon.start('/cli.js', '/workspace-b'); + childB.emitStdout('qwen serve listening on http://127.0.0.1:4102\n'); + await startB; + + expect(childA.kill).toHaveBeenCalled(); + childA.emitExit(0, 'SIGTERM'); + expect(onExit).not.toHaveBeenCalled(); + + // The live daemon dying is still reported. + childB.emitExit(1, null); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('notifies every registered listener when the live daemon exits', async () => { + const childA = createFakeChild(); + spawnMock.mockReturnValueOnce(childA.process); + + const daemon = new QwenDaemonProcess(); + const first = vi.fn(); + const second = vi.fn(); + daemon.addExitListener(first); + daemon.addExitListener(second); + + await settle(daemon, childA, '/workspace-a', 4101); + childA.emitExit(1, null); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('does not notify listeners whose subscription was disposed', async () => { + const childA = createFakeChild(); + spawnMock.mockReturnValueOnce(childA.process); + + const daemon = new QwenDaemonProcess(); + const onExit = vi.fn(); + const handle = daemon.addExitListener(onExit); + + await settle(daemon, childA, '/workspace-a', 4101); + handle.dispose(); + childA.emitExit(1, null); + + expect(onExit).not.toHaveBeenCalled(); + }); + + it('does not report the exit of a child killed by dispose()', async () => { + const childA = createFakeChild(); + spawnMock.mockReturnValueOnce(childA.process); + + const daemon = new QwenDaemonProcess(); + const onExit = vi.fn(); + daemon.addExitListener(onExit); + + await settle(daemon, childA, '/workspace-a', 4101); + + daemon.dispose(); + childA.emitExit(0, 'SIGTERM'); + + expect(onExit).not.toHaveBeenCalled(); + }); + + it('notifies superseded listeners when a workspace switch replaces the live daemon', async () => { + const childA = createFakeChild(); + const childB = createFakeChild(); + spawnMock + .mockReturnValueOnce(childA.process) + .mockReturnValueOnce(childB.process); + + const daemon = new QwenDaemonProcess(); + const onExit = vi.fn(); + const onSuperseded = vi.fn(); + daemon.addExitListener(onExit); + daemon.addSupersededListener(onSuperseded); + + await settle(daemon, childA, '/workspace-a', 4101); + + // The switch must tell hosts still bound to the old runtime that it is + // gone — the suppressed exit notification never reaches them. + const startB = daemon.start('/cli.js', '/workspace-b'); + expect(onSuperseded).toHaveBeenCalledTimes(1); + + childB.emitStdout('qwen serve listening on http://127.0.0.1:4102\n'); + await startB; + + childA.emitExit(0, 'SIGTERM'); + expect(onExit).not.toHaveBeenCalled(); + expect(onSuperseded).toHaveBeenCalledTimes(1); + }); + + it('does not treat dispose() as a supersede', async () => { + const childA = createFakeChild(); + spawnMock.mockReturnValueOnce(childA.process); + + const daemon = new QwenDaemonProcess(); + const onSuperseded = vi.fn(); + daemon.addSupersededListener(onSuperseded); + + await settle(daemon, childA, '/workspace-a', 4101); + daemon.dispose(); + + expect(onSuperseded).not.toHaveBeenCalled(); + }); + + it('stops retaining daemon output once startup settles', async () => { + const childA = createFakeChild(); + spawnMock.mockReturnValueOnce(childA.process); + + const daemon = new QwenDaemonProcess(); + await settle(daemon, childA, '/workspace-a', 4101); + + // The daemon logs continuously for the whole IDE session; keeping the + // data handlers attached would retain every byte in the extension host. + expect(childA.listenerCount('stdout:data')).toBe(0); + expect(childA.listenerCount('stderr:data')).toBe(0); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenDaemonProcess.ts b/packages/vscode-ide-companion/src/services/qwenDaemonProcess.ts new file mode 100644 index 00000000000..321c97f43df --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenDaemonProcess.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomBytes } from 'node:crypto'; +import { spawn, type ChildProcess } from 'node:child_process'; + +export interface QwenDaemonRuntime { + baseUrl: string; + token: string; +} + +export interface QwenDaemonListenerHandle { + dispose(): void; +} + +const STARTUP_TIMEOUT_MS = 30_000; +const LISTENING_URL = /qwen serve listening on (http:\/\/[^\s]+)/; + +export class QwenDaemonProcess { + private child: ChildProcess | null = null; + private runtime: QwenDaemonRuntime | null = null; + private startup: Promise | null = null; + /** Workspace the live daemon was bound to via `serve --workspace`. */ + private boundWorkspaceCwd: string | null = null; + /** Notified when the live daemon exits after a successful start. */ + private exitListeners = new Set<() => void>(); + /** + * Notified when the live daemon is replaced by a workspace switch, so + * hosts still bound to the old runtime can surface the failure instead of + * hanging against a dead port. + */ + private supersededListeners = new Set<() => void>(); + + addExitListener(listener: () => void): QwenDaemonListenerHandle { + this.exitListeners.add(listener); + return { dispose: () => this.exitListeners.delete(listener) }; + } + + addSupersededListener(listener: () => void): QwenDaemonListenerHandle { + this.supersededListeners.add(listener); + return { dispose: () => this.supersededListeners.delete(listener) }; + } + + start( + cliEntryPath: string, + workspaceCwd: string, + ): Promise { + // A daemon is bound to one workspace at spawn. Reusing it for a different + // root — which a multi-root window hits as soon as a second chat opens + // against another folder — would silently scope every session, history + // page, and prompt to the first root instead. + if ( + this.boundWorkspaceCwd !== null && + this.boundWorkspaceCwd !== workspaceCwd + ) { + this.dispose(); + for (const listener of [...this.supersededListeners]) listener(); + } + if ( + this.runtime && + this.child && + this.child.exitCode === null && + this.boundWorkspaceCwd === workspaceCwd + ) { + return Promise.resolve(this.runtime); + } + if (this.startup) return this.startup; + this.boundWorkspaceCwd = workspaceCwd; + + const token = randomBytes(32).toString('hex'); + this.startup = new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + cliEntryPath, + 'serve', + '--hostname', + '127.0.0.1', + '--port', + '0', + '--workspace', + workspaceCwd, + '--no-web', + '--require-auth', + '--allow-origin', + '*', + ], + { + cwd: workspaceCwd, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE: '1', + QWEN_SERVER_TOKEN: token, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + this.child = child; + + let settled = false; + let output = ''; + const onStdout = (chunk: Buffer) => { + output += chunk.toString(); + const match = LISTENING_URL.exec(output); + if (match?.[1]) finish(undefined, match[1]); + }; + const onStderr = (chunk: Buffer) => { + output += chunk.toString(); + }; + const finish = (error?: Error, baseUrl?: string) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + // The daemon lives for the whole IDE session and logs continuously; + // once startup settles, the stdio handlers must stop retaining every + // byte it writes. + child.stdout?.removeListener('data', onStdout); + child.stderr?.removeListener('data', onStderr); + // `dispose()` clears the shared fields, so an attempt that was already + // replaced (a workspace switch kills the old child while it is still + // starting) must not run it — that would tear down its successor. + if (this.child !== child) { + child.kill(); + reject( + error ?? new Error('Qwen daemon was superseded during startup'), + ); + return; + } + this.startup = null; + if (error || !baseUrl) { + this.dispose(); + reject(error ?? new Error('Qwen daemon did not report its URL')); + return; + } + this.runtime = { baseUrl, token }; + resolve(this.runtime); + }; + + const timeout = setTimeout( + () => + finish( + new Error( + `Timed out starting Qwen daemon${output ? `: ${output.slice(-500)}` : ''}`, + ), + ), + STARTUP_TIMEOUT_MS, + ); + + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.once('error', (error) => finish(error)); + child.once('exit', (code, signal) => { + if (settled) { + // Died after a successful start. Retract the runtime so the next + // start() respawns instead of handing out a dead base URL, and + // report the exit — but only while this child is still the live + // one. A superseded child (a workspace switch kills it) and a + // dispose() kill both still fire exit; reporting those would show + // a crash banner for a healthy replacement daemon or a panel that + // is tearing down on purpose. + if (this.child === child) { + this.child = null; + this.runtime = null; + this.boundWorkspaceCwd = null; + for (const listener of [...this.exitListeners]) listener(); + } + return; + } + finish( + new Error( + `Qwen daemon exited before startup (code=${String(code)}, signal=${String(signal)})${output ? `: ${output.slice(-500)}` : ''}`, + ), + ); + }); + }); + return this.startup; + } + + dispose(): void { + this.child?.kill(); + this.child = null; + this.runtime = null; + this.startup = null; + this.boundWorkspaceCwd = null; + } +} diff --git a/packages/vscode-ide-companion/src/services/sessionExportService.ts b/packages/vscode-ide-companion/src/services/sessionExportService.ts index 7f202c2f1da..cdabc5cde1d 100644 --- a/packages/vscode-ide-companion/src/services/sessionExportService.ts +++ b/packages/vscode-ide-companion/src/services/sessionExportService.ts @@ -23,7 +23,7 @@ import { isSessionExportFormat, type SessionExportFormat, } from '../utils/exportSlashCommand.js'; -import { stripZeroWidthSpaces } from '@qwen-code/webui'; +import { stripZeroWidthSpaces } from '../utils/inputPlaceholder.js'; export { EXPORT_SESSION_FORMATS as SESSION_EXPORT_FORMATS }; export type { SessionExportFormat } from '../utils/exportSlashCommand.js'; diff --git a/packages/vscode-ide-companion/src/types/completionItemTypes.ts b/packages/vscode-ide-companion/src/types/completionItemTypes.ts deleted file mode 100644 index eb105f77a0d..00000000000 --- a/packages/vscode-ide-companion/src/types/completionItemTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export type { CompletionItem, CompletionItemType } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts index 76025b6b16e..0e90ffcd7b2 100644 --- a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts +++ b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts @@ -23,3 +23,9 @@ export interface AskUserQuestionResponseMessage { type: string; data: AskUserQuestionResponsePayload; } + +export interface InlineFilePayload { + name: string; + mediaType: string; + text: string; +} diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts index 9f277dde14e..f08ca66dbf8 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts @@ -28,10 +28,10 @@ describe('imageSupport browser bundling', () => { expect(output).not.toContain('supportedImageFormats.js'); }); - it('does not leave qwen-code-core runtime imports in the App webview bundle', async () => { + it('does not leave qwen-code-core runtime imports in the embedded webview bundle', async () => { const result = await esbuild.build({ entryPoints: [ - fileURLToPath(new URL('../webview/App.tsx', import.meta.url)), + fileURLToPath(new URL('../webview/EmbeddedApp.tsx', import.meta.url)), ], bundle: true, format: 'iife', diff --git a/packages/vscode-ide-companion/src/utils/inputPlaceholder.ts b/packages/vscode-ide-companion/src/utils/inputPlaceholder.ts new file mode 100644 index 00000000000..0237f4db60b --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/inputPlaceholder.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ZERO_WIDTH_SPACE = '\u200B'; + +export function stripZeroWidthSpaces(text: string): string { + return text.replaceAll(ZERO_WIDTH_SPACE, ''); +} diff --git a/packages/vscode-ide-companion/src/webview/App.test.tsx b/packages/vscode-ide-companion/src/webview/App.test.tsx deleted file mode 100644 index 27b1bf00722..00000000000 --- a/packages/vscode-ide-companion/src/webview/App.test.tsx +++ /dev/null @@ -1,1648 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import type React from 'react'; -import { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { flushSync } from 'react-dom'; -import { createRoot, type Root } from 'react-dom/client'; -import type { CompletionItem } from '../types/completionItemTypes.js'; - -const { - mockPostMessage, - mockOpenCompletion, - mockCloseCompletion, - mockMessageState, - mockMessages, - mockInsightState, - mockAddMessage, - mockEndStreaming, - mockWebShellTranscriptProps, - mockWebShellLoadFailure, - mockCompletionState, - mockModelSelectorMounts, - capturedWebViewHandlers, - capturedSessionHandlers, - capturedCompletionTriggerCalls, -} = vi.hoisted(() => ({ - mockPostMessage: vi.fn(), - mockOpenCompletion: vi.fn().mockResolvedValue(undefined), - mockCloseCompletion: vi.fn(), - mockMessageState: { - isStreaming: false, - isWaitingForResponse: false, - }, - mockMessages: [] as Array<{ - role: string; - content: string; - timestamp: number; - localOnly?: boolean; - }>, - mockInsightState: { - progress: null as null | { - stage: string; - progress: number; - detail?: string; - }, - reportPath: null as null | string, - }, - mockAddMessage: vi.fn(), - mockEndStreaming: vi.fn(), - mockWebShellTranscriptProps: { - current: null as null | Record, - }, - mockWebShellLoadFailure: { current: false }, - // Completion menu visibility for the mocked useCompletionTrigger. Tests - // that exercise composer key handling without the menu flip this to - // false; the default true keeps the /skills picker suites working. - mockCompletionState: { - isOpen: true, - }, - // Records every render in which InputForm receives showModelSelector=true, - // so tests can detect even a transient selector mount (the open gate must - // prevent mounting entirely, not merely get cleaned up after the fact). - mockModelSelectorMounts: vi.fn(), - // The mocked useWebViewMessages stores the real App setters here so tests - // can deliver overlay arrivals (permission / question / account) directly. - capturedWebViewHandlers: { - handlePermissionRequest: null as null | ((value: unknown) => void), - handleAskUserQuestion: null as null | ((value: unknown) => void), - setAccountInfo: null as null | ((value: unknown) => void), - }, - // The mocked useSessionManagement exposes its real setShowSessionSelector - // setter here so tests can raise the SessionSelector overlay directly. - capturedSessionHandlers: { - setShowSessionSelector: null as null | ((value: boolean) => void), - }, - // Every call the App makes to the mocked useCompletionTrigger, so tests - // can assert the suppression argument is actually wired at the call site. - capturedCompletionTriggerCalls: [] as unknown[][], -})); - -const slashSkillsItem: CompletionItem = { - id: 'skills', - label: '/skills', - type: 'command', - value: 'skills', -}; - -const modelCommandItem: CompletionItem = { - id: 'model', - label: '/model', - type: 'command', - value: 'model', -}; - -const secondarySkillItem: CompletionItem = { - id: 'skill:code-review', - label: 'code-review', - type: 'command', - value: 'skills code-review', -}; - -const commitCommandItem: CompletionItem = { - id: 'commit', - label: '/commit', - type: 'command', - value: 'commit', -}; - -const clearCommandItem: CompletionItem = { - id: 'clear', - label: '/clear', - type: 'command', - value: 'clear', -}; - -vi.mock('./hooks/useVSCode.js', () => ({ - useVSCode: () => ({ - postMessage: mockPostMessage, - }), -})); - -vi.mock('./hooks/session/useSessionManagement.js', async () => { - const React = await import('react'); - return { - useSessionManagement: () => { - const [showSessionSelector, setShowSessionSelector] = - React.useState(false); - capturedSessionHandlers.setShowSessionSelector = setShowSessionSelector; - return { - showSessionSelector, - filteredSessions: [], - currentSessionId: 'session-1', - sessionSearchQuery: '', - setSessionSearchQuery: vi.fn(), - handleSwitchSession: vi.fn(), - setShowSessionSelector, - hasMore: false, - isLoading: false, - handleLoadMoreSessions: vi.fn(), - handleLoadQwenSessions: vi.fn(), - handleNewQwenSession: vi.fn(), - currentSessionTitle: 'Session 1', - }; - }, - }; -}); - -vi.mock('./hooks/file/useFileContext.js', () => ({ - useFileContext: () => ({ - hasRequestedFiles: false, - workspaceFiles: [], - requestWorkspaceFiles: vi.fn(), - addFileReference: vi.fn(), - activeFileName: null, - activeSelection: null, - focusActiveEditor: vi.fn(), - }), -})); - -vi.mock('./hooks/message/useMessageHandling.js', () => ({ - useMessageHandling: () => ({ - messages: mockMessages, - isStreaming: mockMessageState.isStreaming, - isWaitingForResponse: mockMessageState.isWaitingForResponse, - addMessage: mockAddMessage, - endStreaming: mockEndStreaming, - setWaitingForResponse: vi.fn(), - }), -})); - -vi.mock('./hooks/useToolCalls.js', () => ({ - useToolCalls: () => ({ - inProgressToolCalls: [], - completedToolCalls: [], - handleToolCallUpdate: vi.fn(), - clearToolCalls: vi.fn(), - }), -})); - -vi.mock('./hooks/useWebViewMessages.js', async () => { - const React = await import('react'); - return { - useWebViewMessages: ({ - setIsAuthenticated, - setAvailableCommands, - setAvailableSkills, - setInsightProgress, - setInsightReportPath, - handlePermissionRequest, - handleAskUserQuestion, - setAccountInfo, - }: { - setIsAuthenticated: (value: boolean) => void; - setAvailableCommands: ( - value: Array<{ - name: string; - description: string; - input?: { hint: string } | null; - }>, - ) => void; - setAvailableSkills: (value: string[]) => void; - setInsightProgress?: ( - value: { stage: string; progress: number; detail?: string } | null, - ) => void; - setInsightReportPath?: (value: string | null) => void; - handlePermissionRequest: (value: unknown) => void; - handleAskUserQuestion: (value: unknown) => void; - setAccountInfo: (value: unknown) => void; - }) => { - capturedWebViewHandlers.handlePermissionRequest = handlePermissionRequest; - capturedWebViewHandlers.handleAskUserQuestion = handleAskUserQuestion; - capturedWebViewHandlers.setAccountInfo = setAccountInfo; - - const initializedRef = React.useRef(false); - - React.useEffect(() => { - if (initializedRef.current) { - return; - } - initializedRef.current = true; - setIsAuthenticated(true); - if (mockInsightState.progress) { - setInsightProgress?.(mockInsightState.progress); - } - if (mockInsightState.reportPath) { - setInsightReportPath?.(mockInsightState.reportPath); - } - setAvailableCommands([ - { - name: 'skills', - description: 'List available skills', - input: null, - }, - { - name: 'commit', - description: 'Commit current changes', - input: { hint: '' }, - }, - { - name: 'clear', - description: 'Clear the chat', - input: null, - }, - ]); - setAvailableSkills(['code-review']); - }, [ - setAvailableCommands, - setAvailableSkills, - setIsAuthenticated, - setInsightProgress, - setInsightReportPath, - ]); - }, - }; -}); - -vi.mock('./hooks/useMessageSubmit.js', () => ({ - useMessageSubmit: () => ({ - handleSubmit: vi.fn(), - }), - shouldSendMessage: () => true, -})); - -vi.mock('./hooks/useImage.js', () => ({ - useImagePaste: () => ({ - attachedImages: [], - handleRemoveImage: vi.fn(), - clearImages: vi.fn(), - handlePaste: vi.fn(), - }), -})); - -vi.mock('./hooks/useCompletionTrigger.js', async () => { - const React = await import('react'); - return { - // Record every call so tests can assert the App actually passes its - // suppression argument (third positional parameter) — reverting the call - // site leaves it undefined and fails that assertion. The returned object - // is memoized on isOpen (as a memoized real hook would): a fresh object - // every render would recreate handleCompletionSelect every render and - // mask a missing overlay-gate dependency entry. - useCompletionTrigger: (...args: unknown[]) => { - capturedCompletionTriggerCalls.push(args); - const isOpen = mockCompletionState.isOpen; - return React.useMemo( - () => ({ - isOpen, - triggerChar: '/', - query: 'skills ', - items: [ - slashSkillsItem, - modelCommandItem, - secondarySkillItem, - commitCommandItem, - clearCommandItem, - ], - closeCompletion: mockCloseCompletion, - openCompletion: mockOpenCompletion, - refreshCompletion: vi.fn(), - }), - [isOpen], - ); - }, - }; -}); - -vi.mock('./utils/contextUsage.js', () => ({ - computeContextUsage: () => null, -})); - -vi.mock('./utils/utils.js', () => ({ - hasToolCallOutput: () => false, -})); - -vi.mock('./components/messages/toolcalls/ToolCall.js', () => ({ - ToolCall: () => null, -})); - -vi.mock('./components/layout/Onboarding.js', () => ({ - Onboarding: () => null, -})); - -vi.mock('./components/AccountInfoDialog.js', () => ({ - AccountInfoDialog: () => null, -})); - -vi.mock('@qwen-code/webui', () => ({ - AssistantMessage: () => null, - UserMessage: () => null, - ThinkingMessage: () => null, - WaitingMessage: () => null, - InterruptedMessage: () => null, - FileIcon: () => null, - AskUserQuestionDialog: () => null, - ImageMessageRenderer: () => null, - ImagePreview: () => null, - EmptyState: () => null, - ChatHeader: () => null, - SessionSelector: () => null, - // Renders a marker so tests can assert the overlay's presence in the DOM - // (e.g. that the model selector is already gone by the time the overlay - // commits — see the overlay-takeover timing test). - PermissionDrawer: () =>
, - InsightProgressCard: ({ - stage, - progress, - }: { - stage: string; - progress: number; - detail?: string; - }) => `${stage} ${Math.round(progress)}%`, - ZERO_WIDTH_SPACE: '\u200B', - CloseSmallIcon: () => null, - stripZeroWidthSpaces: (text: string) => text.replace(/\u200B/g, ''), -})); - -vi.mock('./components/layout/InputForm.js', () => ({ - InputForm: ({ - inputText, - inputFieldRef, - onCancel, - onCompletionSelect, - onCompletionFill, - onKeyDown, - showModelSelector, - onModelSelectorClearance, - }: { - inputText: string; - inputFieldRef: React.RefObject; - onCancel: () => void; - onCompletionSelect: (item: CompletionItem) => void; - onCompletionFill?: (item: CompletionItem) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - showModelSelector?: boolean; - onModelSelectorClearance?: (heightPx: number) => void; - }) => { - if (showModelSelector) { - mockModelSelectorMounts(); - } - return ( -
-
- {inputText} -
-
{inputText}
- {showModelSelector && ( -
- )} - - - - - - - - -
- ); - }, -})); - -vi.mock('@qwen-code/web-shell', () => ({ - WebShellTranscript: (props: Record) => { - // Simulate the lazy chunk failing to load (e.g. a retained webview - // fetching a content-hashed chunk that an extension auto-update - // removed). Like a rejected dynamic import, the failure surfaces as a - // render error escaping Suspense, which only an ErrorBoundary catches. - if (mockWebShellLoadFailure.current) { - throw new Error( - 'Failed to fetch dynamically imported module: chunks/web-shell.js', - ); - } - mockWebShellTranscriptProps.current = props; - return null; - }, -})); - -import { App } from './App.js'; - -function createDomRect(): DOMRect { - return { - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - right: 0, - bottom: 0, - left: 0, - toJSON: () => ({}), - } as DOMRect; -} - -function clickButton(container: HTMLDivElement, label: string) { - const button = Array.from(container.querySelectorAll('button')).find( - (candidate) => candidate.textContent === label, - ); - if (!button) { - throw new Error(`Button not found: ${label}`); - } - act(() => { - button.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ); - }); -} - -function setInputSelection(container: HTMLDivElement, text: string) { - const input = container.querySelector( - '[data-testid="input-field"]', - ) as HTMLDivElement | null; - if (!input) { - throw new Error('Input field not found'); - } - - act(() => { - input.textContent = text; - if (!input.firstChild) { - input.appendChild(document.createTextNode(text)); - } else { - input.firstChild.textContent = text; - } - - const textNode = input.firstChild; - if (!textNode) { - throw new Error('Missing text node'); - } - - const selection = window.getSelection(); - const range = document.createRange(); - range.setStart(textNode, text.length); - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - }); -} - -function getRenderedInputText(container: HTMLDivElement): string { - return ( - container.querySelector('[data-testid="input-text"]')?.textContent ?? '' - ); -} - -function renderApp() { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - act(() => { - root.render(); - }); - - return { container, root }; -} - -/** Reset via a call so tsc does not narrow the prop capture to `null`. */ -function resetWebShellTranscriptProps(): void { - mockWebShellTranscriptProps.current = null; -} - -describe('App /skills secondary picker', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - beforeEach(() => { - vi.clearAllMocks(); - mockMessages.length = 0; - mockInsightState.progress = null; - mockInsightState.reportPath = null; - mockMessageState.isStreaming = false; - mockMessageState.isWaitingForResponse = false; - mockWebShellLoadFailure.current = false; - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: vi.fn(), - }); - Object.defineProperty(HTMLElement.prototype, 'scrollTo', { - configurable: true, - value: vi.fn(), - }); - Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { - configurable: true, - value: () => createDomRect(), - }); - Object.defineProperty(Range.prototype, 'getBoundingClientRect', { - configurable: true, - value: () => createDomRect(), - }); - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - value: class { - observe() {} - disconnect() {} - }, - }); - Object.defineProperty(globalThis, 'requestAnimationFrame', { - configurable: true, - value: (callback: FrameRequestCallback) => { - callback(0); - return 1; - }, - }); - Object.defineProperty(globalThis, 'cancelAnimationFrame', { - configurable: true, - value: vi.fn(), - }); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('opens the secondary picker after selecting /skills', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/'); - - clickButton(rendered.container, 'select-skills-command'); - - expect(mockPostMessage).not.toHaveBeenCalled(); - expect(mockOpenCompletion).toHaveBeenCalledWith( - '/', - 'skills ', - expect.any(Object), - ); - }); - - it('sends /skills when pressing Enter on a skill item', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/skills '); - - clickButton(rendered.container, 'select-skill-enter'); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { text: '/skills code-review' }, - }); - expect(mockCloseCompletion).toHaveBeenCalled(); - }); - - it('fills /skills without sending when pressing Tab on a skill item', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/skills '); - - clickButton(rendered.container, 'select-skill-tab'); - - expect(mockPostMessage).not.toHaveBeenCalled(); - expect(getRenderedInputText(rendered.container)).toBe( - '/skills code-review ', - ); - }); - - it('fills slash commands that declare input when pressing Enter', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/'); - - clickButton(rendered.container, 'select-commit-enter'); - - expect(mockPostMessage).not.toHaveBeenCalled(); - expect(getRenderedInputText(rendered.container)).toBe('/commit '); - expect(mockCloseCompletion).toHaveBeenCalled(); - }); - - it('auto-submits slash commands without input when pressing Enter', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/'); - - clickButton(rendered.container, 'select-clear-enter'); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { text: '/clear' }, - }); - expect(mockCloseCompletion).toHaveBeenCalled(); - }); - - it('fills slash commands without input when pressing Tab', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/'); - - clickButton(rendered.container, 'select-clear-tab'); - - expect(mockPostMessage).not.toHaveBeenCalled(); - expect(getRenderedInputText(rendered.container)).toBe('/clear '); - }); - - it('blurs and preserves composer text on idle cancel without cancelling the session', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, 'draft after escape'); - - const input = rendered.container.querySelector( - '[data-testid="input-field"]', - ) as HTMLDivElement; - const blurSpy = vi.spyOn(input, 'blur'); - - clickButton(rendered.container, 'cancel-input'); - - expect(blurSpy).toHaveBeenCalled(); - expect(input.getAttribute('data-empty')).toBe('false'); - expect(getRenderedInputText(rendered.container)).toBe('draft after escape'); - expect(mockPostMessage).not.toHaveBeenCalledWith({ - type: 'cancelStreaming', - data: {}, - }); - }); - - it('still cancels the session while streaming', async () => { - mockMessageState.isStreaming = true; - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - clickButton(rendered.container, 'cancel-input'); - - expect(mockEndStreaming).toHaveBeenCalled(); - expect(mockAddMessage).toHaveBeenCalledWith( - expect.objectContaining({ - role: 'assistant', - content: 'Interrupted', - // The transcript only renders ACP frames; the local cancel mark - // must carry the localOnly flag or it is never shown. - localOnly: true, - }), - ); - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'cancelStreaming', - data: {}, - }); - }); - - it('renders locally generated messages in a notice slot beside the transcript', async () => { - mockMessages.push( - { role: 'user', content: 'ordinary history', timestamp: 1 }, - { - role: 'assistant', - content: 'Failed to connect to Qwen agent: spawn failed', - timestamp: 2, - localOnly: true, - }, - { - role: 'assistant', - content: 'Interrupted', - timestamp: 3, - localOnly: true, - }, - ); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const notices = rendered.container.querySelectorAll( - '[data-testid="local-message-notice"]', - ); - expect(notices).toHaveLength(2); - expect(notices[0]?.textContent).toContain( - 'Failed to connect to Qwen agent: spawn failed', - ); - expect(notices[1]?.textContent).toBe('Interrupted'); - // Extension-provided history must not leak into the notice slot. - expect( - rendered.container.querySelector('[data-testid="local-message-notices"]') - ?.textContent, - ).not.toContain('ordinary history'); - }); - - it('posts openFile when a file link inside the transcript area is clicked', async () => { - mockMessages.push({ - role: 'assistant', - content: 'see the report', - timestamp: 1, - localOnly: true, - }); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const area = rendered.container.querySelector( - '.flex-1.min-h-0.relative', - ) as HTMLDivElement; - expect(area).not.toBeNull(); - - const link = document.createElement('a'); - link.setAttribute('href', '/tmp/insight-report.md'); - link.textContent = '/tmp/insight-report.md'; - area.appendChild(link); - - act(() => { - link.dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'openFile', - data: { path: '/tmp/insight-report.md' }, - }); - }); - - it('leaves external links alone when clicked in the transcript area', async () => { - mockMessages.push({ - role: 'assistant', - content: 'docs', - timestamp: 1, - localOnly: true, - }); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const area = rendered.container.querySelector( - '.flex-1.min-h-0.relative', - ) as HTMLDivElement; - const link = document.createElement('a'); - link.setAttribute('href', 'https://example.com/docs'); - link.textContent = 'docs'; - area.appendChild(link); - - // VS Code webviews never navigate on external links; cancel the jsdom - // navigation without interfering with the handler under test. - const preventNavigation = (event: Event) => event.preventDefault(); - document.addEventListener('click', preventNavigation); - try { - act(() => { - link.dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ); - }); - } finally { - document.removeEventListener('click', preventNavigation); - } - - expect(mockPostMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'openFile' }), - ); - }); - - it('exposes the chat-messages webviewSection context key when content exists', async () => { - mockMessages.push({ - role: 'assistant', - content: 'notice', - timestamp: 1, - localOnly: true, - }); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const area = rendered.container.querySelector( - '.flex-1.min-h-0.relative', - ) as HTMLDivElement; - // The contributed copy commands filter on - // `when: "webviewSection == 'chat-messages'"`; without the attribute - // the context-menu items never appear. - expect(area.getAttribute('data-vscode-context')).toBe( - '{"webviewSection": "chat-messages"}', - ); - }); - - it('reports contextMenuTriggered so copy commands route to this webview', async () => { - mockMessages.push({ - role: 'assistant', - content: 'notice', - timestamp: 1, - localOnly: true, - }); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - mockPostMessage.mockClear(); - - act(() => { - document.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'contextMenuTriggered', - data: {}, - }); - }); - - async function renderAppWithTranscriptText(text: string) { - mockMessages.push({ - role: 'assistant', - content: 'notice', - timestamp: 1, - localOnly: true, - }); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - // Feed one assistant block through the real transcript reducer. - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'transcriptUpdate', - data: { - sessionId: 'session-copy', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text }, - }, - }, - }, - }), - ); - }); - - return rendered; - } - - it('copies the last assistant reply on copyLastReply', async () => { - await renderAppWithTranscriptText('reply text'); - mockPostMessage.mockClear(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'copyCommand', data: { action: 'copyLastReply' } }, - }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'copyToClipboard', - data: { text: 'reply text' }, - }); - }); - - it('copies labeled conversation text on copyAllMessages', async () => { - const rendered = await renderAppWithTranscriptText('reply text'); - void rendered; - - // Also feed a user block so the labeled format is exercised. - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'transcriptUpdate', - data: { - sessionId: 'session-copy', - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'the question' }, - }, - }, - }, - }), - ); - }); - mockPostMessage.mockClear(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'copyCommand', data: { action: 'copyAllMessages' } }, - }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'copyToClipboard', - data: { - text: '**Qwen Code:** reply text\n\n---\n\n**User:** the question', - }, - }); - }); - - it('copies the block under the cursor on copyMessage', async () => { - const rendered = await renderAppWithTranscriptText('reply text'); - - const area = rendered.container.querySelector( - '.flex-1.min-h-0.relative', - ) as HTMLDivElement; - // Simulate a MessageList row for the first assistant block - // (reducer block ids are `assistant-`, ordinal starts at 1). - const row = document.createElement('div'); - row.setAttribute('data-message-row-key', 'msg:assistant-1'); - row.textContent = 'reply text'; - area.appendChild(row); - - act(() => { - row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); - }); - mockPostMessage.mockClear(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'copyCommand', data: { action: 'copyMessage' } }, - }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'copyToClipboard', - data: { text: 'reply text' }, - }); - }); - - it('copies the tool block under a tool-group row key on copyMessage', async () => { - const rendered = await renderAppWithTranscriptText('reply text'); - - // Feed a tool block through the real transcript reducer. Block ids use - // one shared ordinal across kinds, so the assistant block is - // `assistant-1` and this tool block becomes `tool-2`. - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'transcriptUpdate', - data: { - sessionId: 'session-copy', - update: { - sessionUpdate: 'tool_call', - toolCallId: 'call-1', - title: 'Read file', - status: 'completed', - }, - }, - }, - }), - ); - }); - - const area = rendered.container.querySelector( - '.flex-1.min-h-0.relative', - ) as HTMLDivElement; - // MessageList keys tool-group rows as `msg:tg-`. - const row = document.createElement('div'); - row.setAttribute('data-message-row-key', 'msg:tg-tool-2'); - row.textContent = 'Read file'; - area.appendChild(row); - - act(() => { - row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); - }); - mockPostMessage.mockClear(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'copyCommand', data: { action: 'copyMessage' } }, - }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'copyToClipboard', - data: { text: 'Read file' }, - }); - }); - - it('renders /insight progress updates from the insightProgress setter', async () => { - mockInsightState.progress = { stage: 'Analyzing', progress: 40 }; - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const card = rendered.container.querySelector( - '[data-testid="insight-progress"]', - ); - expect(card).not.toBeNull(); - expect(card?.textContent).toContain('Analyzing'); - expect(card?.textContent).toContain('40%'); - }); - - it('surfaces the generated insight report path and opens it on click', async () => { - mockInsightState.reportPath = '/tmp/insight-report.md'; - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const link = rendered.container.querySelector( - '[data-testid="insight-report-link"]', - ) as HTMLAnchorElement; - expect(link).not.toBeNull(); - expect(link.textContent).toBe('/tmp/insight-report.md'); - - mockPostMessage.mockClear(); - act(() => { - link.dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'openInsightReport', - data: { path: '/tmp/insight-report.md' }, - }); - }); - - it('disables WebShell transcript turn auto-collapse while a response is in flight', async () => { - mockMessageState.isStreaming = true; - resetWebShellTranscriptProps(); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - // WebShellTranscript hardcodes isResponding={false}; without an - // explicit opt-out, MessageList would auto-collapse the in-progress - // turn mid-response. - const transcriptProps = mockWebShellTranscriptProps.current; - expect(transcriptProps).not.toBeNull(); - expect(transcriptProps!.collapseCompletedTurns).toBe(false); - }); - - it('reserves measured selector clearance on the WebShell transcript scroll area', async () => { - mockMessageState.isStreaming = true; - resetWebShellTranscriptProps(); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - expect(mockWebShellTranscriptProps.current).not.toBeNull(); - expect(mockWebShellTranscriptProps.current!.style).toMatchObject({ - '--web-shell-bottom-panel-inset': '0px', - }); - - setInputSelection(rendered.container, '/'); - clickButton(rendered.container, 'select-model-command'); - - expect(mockWebShellTranscriptProps.current!.style).toMatchObject({ - '--web-shell-bottom-panel-inset': '192px', - }); - }); - - it('tracks live VS Code color-theme changes on the transcript theme prop', async () => { - mockMessageState.isStreaming = true; - resetWebShellTranscriptProps(); - document.body.setAttribute('data-vscode-theme-kind', 'vscode-dark'); - - try { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - expect(mockWebShellTranscriptProps.current).not.toBeNull(); - expect(mockWebShellTranscriptProps.current!.theme).toBe('dark'); - - // VS Code applies a color-theme change to an open webview in place by - // updating the body attribute (no reload); the transcript theme must - // follow instead of staying on the mount-time snapshot. - await act(async () => { - document.body.setAttribute('data-vscode-theme-kind', 'vscode-light'); - await Promise.resolve(); - }); - - expect(mockWebShellTranscriptProps.current!.theme).toBe('light'); - } finally { - document.body.removeAttribute('data-vscode-theme-kind'); - } - }); - - it('shows a recoverable error state instead of blanking the panel when the transcript chunk fails to load', async () => { - mockMessageState.isStreaming = true; - mockWebShellLoadFailure.current = true; - // React logs errors that an error boundary catches; keep output clean. - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - try { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const fallback = rendered.container.querySelector( - '[data-testid="transcript-load-error"]', - ); - expect(fallback).not.toBeNull(); - expect(fallback?.textContent).toContain('failed to load'); - expect(fallback?.textContent).toContain('Reload panel'); - - // The boundary must be scoped to the transcript subtree: the rest of - // the panel (here the composer) has to survive the chunk failure - // instead of being unmounted with the whole root. - expect( - rendered.container.querySelector('[data-testid="input-field"]'), - ).not.toBeNull(); - } finally { - consoleErrorSpy.mockRestore(); - } - }); - - it('feeds reduced transcript blocks from transcriptUpdate messages into the WebShell transcript', async () => { - mockMessageState.isStreaming = true; - resetWebShellTranscriptProps(); - - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - expect(mockWebShellTranscriptProps.current).not.toBeNull(); - const blocksBefore = JSON.stringify( - mockWebShellTranscriptProps.current!.blocks, - ); - - // The extension forwards every ACP session/update notification as a - // `transcriptUpdate` webview message; useAcpTranscript reduces it and - // App passes the resulting blocks to the renderer. - await act(async () => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'transcriptUpdate', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'timeline-block-content' }, - }, - }, - }, - }), - ); - }); - - expect(mockWebShellTranscriptProps.current).not.toBeNull(); - const blocks = mockWebShellTranscriptProps.current!.blocks; - expect(Array.isArray(blocks)).toBe(true); - expect((blocks as unknown[]).length).toBeGreaterThan(0); - expect(JSON.stringify(blocks)).toContain('timeline-block-content'); - // The prop must change because of the message, proving the reduced - // blocks are actually wired through rather than a static prop. - expect(JSON.stringify(blocks)).not.toBe(blocksBefore); - }); -}); - -describe('App model selector gating', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - const permissionPayload = { - options: [{ optionId: 'allow_once', name: 'Allow once' }], - toolCall: { title: 'run: ls' }, - }; - const askQuestionPayload = { - sessionId: 'session-1', - questions: [ - { - question: 'Which one?', - header: 'Pick', - options: [{ label: 'A', description: 'option a' }], - multiSelect: false, - }, - ], - }; - const accountPayload = { authType: 'qwen-oauth' }; - - beforeEach(() => { - vi.clearAllMocks(); - mockMessages.length = 0; - mockInsightState.progress = null; - mockInsightState.reportPath = null; - mockMessageState.isStreaming = false; - mockMessageState.isWaitingForResponse = false; - mockWebShellLoadFailure.current = false; - mockCompletionState.isOpen = true; - capturedWebViewHandlers.handlePermissionRequest = null; - capturedWebViewHandlers.handleAskUserQuestion = null; - capturedWebViewHandlers.setAccountInfo = null; - capturedSessionHandlers.setShowSessionSelector = null; - capturedCompletionTriggerCalls.length = 0; - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: vi.fn(), - }); - Object.defineProperty(HTMLElement.prototype, 'scrollTo', { - configurable: true, - value: vi.fn(), - }); - Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { - configurable: true, - value: () => createDomRect(), - }); - Object.defineProperty(Range.prototype, 'getBoundingClientRect', { - configurable: true, - value: () => createDomRect(), - }); - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - value: class { - observe() {} - disconnect() {} - }, - }); - Object.defineProperty(globalThis, 'requestAnimationFrame', { - configurable: true, - value: (callback: FrameRequestCallback) => { - callback(0); - return 1; - }, - }); - Object.defineProperty(globalThis, 'cancelAnimationFrame', { - configurable: true, - value: vi.fn(), - }); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - /** Render the app and open the model selector via the /model action. */ - async function renderWithOpenSelector() { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - setInputSelection(rendered.container, '/'); - clickButton(rendered.container, 'select-model-command'); - - // Control for the close tests below: the open path works when no - // overlay is up. - expect(rendered.container.querySelector('.model-selector')).not.toBeNull(); - return rendered; - } - - it.each([ - ['conversationLoaded'], - ['qwenSessionSwitched'], - ['conversationCleared'], - ])( - 'closes the selector on session takeover: %s', - async (messageType: string) => { - await renderWithOpenSelector(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { data: { type: messageType } }), - ); - }); - - expect(container?.querySelector('.model-selector')).toBeNull(); - }, - ); - - // Each case sets exactly ONE overlay state, so mutating the close-effect's - // `||` to `&&` (close only when both/all overlays are up) fails here. - it('closes the selector when a permission request arrives', async () => { - await renderWithOpenSelector(); - - act(() => { - capturedWebViewHandlers.handlePermissionRequest?.(permissionPayload); - }); - - expect(container?.querySelector('.model-selector')).toBeNull(); - }); - - it('closes the selector when an ask-user-question request arrives', async () => { - await renderWithOpenSelector(); - - act(() => { - capturedWebViewHandlers.handleAskUserQuestion?.(askQuestionPayload); - }); - - expect(container?.querySelector('.model-selector')).toBeNull(); - }); - - it('closes the selector when the account info dialog arrives', async () => { - await renderWithOpenSelector(); - - act(() => { - capturedWebViewHandlers.setAccountInfo?.(accountPayload); - }); - - expect(container?.querySelector('.model-selector')).toBeNull(); - }); - - it('closes the selector when the session selector opens', async () => { - await renderWithOpenSelector(); - - act(() => { - capturedSessionHandlers.setShowSessionSelector?.(true); - }); - - expect(container?.querySelector('.model-selector')).toBeNull(); - }); - - it.each([ - [ - 'permission request', - () => - capturedWebViewHandlers.handlePermissionRequest?.(permissionPayload), - ], - [ - 'ask-user-question', - () => capturedWebViewHandlers.handleAskUserQuestion?.(askQuestionPayload), - ], - [ - 'account info', - () => capturedWebViewHandlers.setAccountInfo?.(accountPayload), - ], - [ - 'session selector', - () => capturedSessionHandlers.setShowSessionSelector?.(true), - ], - ])( - 'does not open the selector from /model while a %s is up', - async (_label: string, raiseOverlay: () => void) => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - act(() => { - raiseOverlay(); - }); - - setInputSelection(rendered.container, '/'); - clickButton(rendered.container, 'select-model-command'); - - expect(rendered.container.querySelector('.model-selector')).toBeNull(); - // The open gate must stop the selector before it mounts at all — a - // mount that a close-effect later reverts would still arm the - // selector's capture-phase keydown listener beneath the overlay. - expect(mockModelSelectorMounts).not.toHaveBeenCalled(); - }, - ); - - it('keeps the typed command and the menu when /model is declined under an overlay', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - // Raise the overlay AFTER the completion-select handler exists, then - // select /model from the still-mounted completion menu. The gate must - // decline without stripping the typed trigger text: otherwise the - // composer ends up empty with nothing opened, and the user must dismiss - // the overlay and retype the command. - act(() => { - capturedWebViewHandlers.handlePermissionRequest?.(permissionPayload); - }); - - setInputSelection(rendered.container, '/model'); - clickButton(rendered.container, 'select-model-command'); - - expect(rendered.container.querySelector('.model-selector')).toBeNull(); - expect(mockModelSelectorMounts).not.toHaveBeenCalled(); - // Neither the typed trigger text... - const inputField = rendered.container.querySelector( - '[data-testid="input-field"]', - ); - expect(inputField?.textContent).toBe('/model'); - // ...nor the completion menu is taken away; Enter can retry once the - // overlay clears. - expect(mockCloseCompletion).not.toHaveBeenCalled(); - }); - - /** Dispatch a Tab keydown on the mocked composer input. */ - function dispatchTabOnInput(target: HTMLElement) { - act(() => { - target.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); - }); - } - - // Control for the Tab test below: with the selector closed (and the - // completion menu closed, since an open menu owns Tab for filling), Tab - // on the composer cycles the approval mode and posts setApprovalMode. - it('cycles approval mode with Tab when the selector is closed', async () => { - mockCompletionState.isOpen = false; - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - mockPostMessage.mockClear(); - - const input = rendered.container.querySelector( - '[data-testid="input-field"]', - ); - expect(input).not.toBeNull(); - dispatchTabOnInput(input as HTMLElement); - - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: 'setApprovalMode' }), - ); - }); - - it('does not cycle approval mode with Tab while the selector is open', async () => { - // Completion must be closed here too, so the only thing standing between - // Tab and the approval-mode toggle is the showModelSelector guard — - // removing that guard term makes this test fail. - mockCompletionState.isOpen = false; - await renderWithOpenSelector(); - mockPostMessage.mockClear(); - - const input = container?.querySelector('[data-testid="input-field"]'); - expect(input).not.toBeNull(); - dispatchTabOnInput(input as HTMLElement); - - // The selector does not capture Tab itself; without the guard the - // keystroke silently cycles the approval mode (up to YOLO) underneath - // the open dropdown. - expect(mockPostMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'setApprovalMode' }), - ); - }); - - it('swallows Tab while the selector is open so focus cannot leave the composer', async () => { - // The selector never takes DOM focus. Without a swallow, the browser - // default for Tab moves focus to the next tabbable element while the - // dropdown stays open, and subsequent typing goes nowhere. - mockCompletionState.isOpen = false; - await renderWithOpenSelector(); - - const input = container?.querySelector('[data-testid="input-field"]'); - expect(input).not.toBeNull(); - - const event = new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }); - act(() => { - (input as HTMLElement).dispatchEvent(event); - }); - - expect(event.defaultPrevented).toBe(true); - }); - - it('wires selector visibility into completion suppression', async () => { - const rendered = renderApp(); - root = rendered.root; - container = rendered.container; - - await act(async () => {}); - - const lastSuppressionArg = () => { - const calls = capturedCompletionTriggerCalls; - const last = calls[calls.length - 1]; - return last ? last[2] : undefined; - }; - - // Selector closed: completion is not suppressed. - expect(lastSuppressionArg()).toBe(false); - - setInputSelection(rendered.container, '/'); - clickButton(rendered.container, 'select-model-command'); - expect(rendered.container.querySelector('.model-selector')).not.toBeNull(); - - // Selector open: the App must pass showModelSelector as the hook's - // third argument — dropping it (or hardcoding false) fails here. - expect(lastSuppressionArg()).toBe(true); - }); - - it('unmounts the selector inside the overlay commit, leaving no armed-keydown window', async () => { - await renderWithOpenSelector(); - - // Deliver the overlay the way production does (a raw state update) and - // force a synchronous commit with flushSync. A useLayoutEffect close - // runs inside that commit's flush; a passive useEffect close would only - // be SCHEDULED here, leaving one commit in which the overlay and the - // selector are mounted together — the selector's capture-phase document - // keydown listener armed beneath the visible overlay. - const env = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - }; - const previousActEnvironment = env.IS_REACT_ACT_ENVIRONMENT; - env.IS_REACT_ACT_ENVIRONMENT = false; - try { - flushSync(() => { - capturedWebViewHandlers.handlePermissionRequest?.(permissionPayload); - }); - } finally { - env.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; - } - - // The overlay is up... - expect( - container?.querySelector('[data-testid="permission-drawer"]'), - ).not.toBeNull(); - // ...and the selector is already gone synchronously — no passive-effect - // flush has had a chance to run yet. - expect(container?.querySelector('.model-selector')).toBeNull(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx deleted file mode 100644 index 2b85f0f7c09..00000000000 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ /dev/null @@ -1,1356 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { - useState, - useEffect, - useRef, - useCallback, - useMemo, -} from 'react'; -import { useVSCode } from './hooks/useVSCode.js'; -import { useSessionManagement } from './hooks/session/useSessionManagement.js'; -import { useFileContext } from './hooks/file/useFileContext.js'; -import { useMessageHandling } from './hooks/message/useMessageHandling.js'; -import { useToolCalls } from './hooks/useToolCalls.js'; -import { useWebViewMessages } from './hooks/useWebViewMessages.js'; -import { useAcpTranscript } from './hooks/useAcpTranscript.js'; -import { - shouldSendMessage, - useMessageSubmit, -} from './hooks/useMessageSubmit.js'; -import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; -import { stripZeroWidthSpaces } from '@qwen-code/webui'; -import { Onboarding } from './components/layout/Onboarding.js'; -import { type CompletionItem } from '../types/completionItemTypes.js'; -import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; -import { - FileIcon, - PermissionDrawer, - AskUserQuestionDialog, - ImagePreview, - InsightProgressCard, - // Layout components imported directly from webui - EmptyState, - ChatHeader, - SessionSelector, -} from '@qwen-code/webui'; -import { InputForm } from './components/layout/InputForm.js'; -import { - AccountInfoDialog, - type AccountInfo, -} from './components/AccountInfoDialog.js'; -import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; -import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; -import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; -import type { Question } from '../types/acpTypes.js'; -import { useImagePaste } from './hooks/useImage.js'; -import { computeContextUsage } from './utils/contextUsage.js'; -import { resolveFileLinkFromAnchor } from './utils/fileLinks.js'; -import { - findBlockByRowKey, - findLastAssistantText, - formatBlocksForCopyAll, - getBlockCopyText, -} from './utils/copyTranscript.js'; -import { - SKILL_ITEM_ID_PREFIX, - isSkillsSecondaryQuery, - shouldOpenSkillsSecondaryPicker, -} from './utils/completionUtils.js'; -import { - buildSlashCommandItems, - isExpandableSlashCommand, -} from './utils/slashCommandUtils.js'; -// Lazy-load the WebShell transcript renderer so the ~17MB web-shell chunk -// stays split into its own bundle and is fetched on demand. -const WebShellTranscriptLazy = React.lazy(() => - import('@qwen-code/web-shell').then((module) => ({ - default: module.WebShellTranscript, - })), -); - -/** Map VS Code's body theme attribute onto the transcript's dark/light prop. */ -function readVSCodeWebviewTheme(): 'dark' | 'light' { - const kind = document.body.getAttribute('data-vscode-theme-kind') ?? ''; - return kind.includes('light') ? 'light' : 'dark'; -} - -/** - * Keep a failed lazy chunk load from blanking the whole panel. The - * transcript renderer ships as a content-hashed dynamic import, so a - * webview retained across an extension auto-update can request chunk - * hashes that no longer exist in the new bundle. Suspense does not catch - * the rejected import — React rethrows it and unmounts the entire root — - * so catch it here and show a recoverable error state instead. - */ -class TranscriptErrorBoundary extends React.Component< - { children?: React.ReactNode }, - { hasError: boolean } -> { - state: { hasError: boolean } = { hasError: false }; - - static getDerivedStateFromError(): { hasError: boolean } { - return { hasError: true }; - } - - private handleReload = () => { - // A full webview reload re-requests the HTML entry and picks up the - // current bundle, which is the reliable recovery when the stale - // webview is pinned to chunk hashes that no longer exist. - window.location.reload(); - }; - - render(): React.ReactNode { - if (!this.state.hasError) { - return this.props.children; - } - return ( -
- - The conversation timeline failed to load. - - -
- ); - } -} - -export const App: React.FC = () => { - const vscode = useVSCode(); - - // Core hooks - const sessionManagement = useSessionManagement(vscode); - const fileContext = useFileContext(vscode); - const messageHandling = useMessageHandling(); - const { - inProgressToolCalls, - completedToolCalls, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - } = useToolCalls(); - - // UI state - const [inputText, setInputText] = useState(''); - const [permissionRequest, setPermissionRequest] = useState<{ - options: PermissionOption[]; - toolCall: PermissionToolCall; - } | null>(null); - const [askUserQuestionRequest, setAskUserQuestionRequest] = useState<{ - questions: Question[]; - sessionId: string; - metadata?: { - source?: string; - }; - } | null>(null); - const [planEntries, setPlanEntries] = useState([]); - const [isAuthenticated, setIsAuthenticated] = useState(null); - const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading - const [modelInfo, setModelInfo] = useState(null); - const [usageStats, setUsageStats] = useState(null); - const [availableCommands, setAvailableCommands] = useState< - AvailableCommand[] - >([]); - const [availableSkills, setAvailableSkills] = useState([]); - const [availableModels, setAvailableModels] = useState([]); - const [showModelSelector, setShowModelSelector] = useState(false); - const [modelSelectorClearance, setModelSelectorClearance] = useState(0); - const handleModelSelectorClearanceChange = useCallback((heightPx: number) => { - setModelSelectorClearance(heightPx); - }, []); - const [accountInfo, setAccountInfo] = useState(null); - // /insight feedback: latest structured progress update and the generated - // report path, surfaced by the extension via insightProgress / - // insightReportReady messages. - const [insightProgress, setInsightProgress] = useState<{ - stage: string; - progress: number; - detail?: string; - } | null>(null); - const [insightReportPath, setInsightReportPath] = useState( - null, - ); - const inputFieldRef = useRef(null); - - const [editMode, setEditMode] = useState( - ApprovalMode.DEFAULT, - ); - const [thinkingEnabled, setThinkingEnabled] = useState(false); - const [isComposing, setIsComposing] = useState(false); - // When true, do NOT auto-attach the active editor file/selection to message context - const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false); - - // Completion system - const getCompletionItems = React.useCallback( - async (trigger: '@' | '/', query: string): Promise => { - if (trigger === '@') { - console.log('[App] getCompletionItems @ called', { - query, - requested: fileContext.hasRequestedFiles, - workspaceFiles: fileContext.workspaceFiles.length, - }); - // Always trigger request based on current query, let the hook decide if an actual request is needed - fileContext.requestWorkspaceFiles(query); - - const fileIcon = ; - const allItems: CompletionItem[] = fileContext.workspaceFiles.map( - (file) => ({ - id: file.id, - label: file.label, - description: file.description, - type: 'file' as const, - icon: fileIcon, - // Insert filename after @, keep path for mapping - value: file.label, - path: file.path, - }), - ); - - // Fuzzy search is handled by the backend (FileSearchFactory) - // No client-side filtering needed - results are already fuzzy-matched - - // If first time and still loading, show a placeholder - if (allItems.length === 0 && query && query.length >= 1) { - return [ - { - id: 'loading-files', - label: 'Searching files…', - description: 'Type to filter, or wait a moment…', - type: 'info' as const, - }, - ]; - } - - return allItems; - } else { - if (availableSkills.length > 0 && isSkillsSecondaryQuery(query)) { - const skillQuery = query.replace(/^skills\s+/i, '').toLowerCase(); - return availableSkills - .map( - (skill) => - ({ - id: `${SKILL_ITEM_ID_PREFIX}${skill}`, - label: skill, - type: 'command' as const, - group: 'Skills', - value: `skills ${skill}`, - }) satisfies CompletionItem, - ) - .filter((item) => item.label.toLowerCase().includes(skillQuery)); - } - - // Handle slash commands with grouping - // Model group - special items without / prefix - const modelGroupItems: CompletionItem[] = [ - { - id: 'model', - label: 'Switch model...', - description: modelInfo?.name || 'Default', - type: 'command', - group: 'Model', - }, - ]; - - // Account group - const accountGroupItems: CompletionItem[] = [ - { - id: 'auth', - label: '/auth', - description: 'Configure Coding Plan or API Key', - type: 'command', - group: 'Account', - }, - { - id: 'account', - label: 'Account', - description: 'Show current account and authentication info', - type: 'command', - group: 'Account', - }, - ]; - - const slashCommandItems = buildSlashCommandItems( - query, - availableCommands, - ); - - // Combine all commands - const allCommands = [ - ...modelGroupItems, - ...accountGroupItems, - ...slashCommandItems, - ]; - - // Filter by query - return allCommands.filter( - (cmd) => - cmd.label.toLowerCase().includes(query.toLowerCase()) || - (cmd.description && - cmd.description.toLowerCase().includes(query.toLowerCase())), - ); - } - }, - [fileContext, availableCommands, availableSkills, modelInfo?.name], - ); - - const completion = useCompletionTrigger( - inputFieldRef, - getCompletionItems, - showModelSelector, - ); - const { - isOpen: completionIsOpen, - triggerChar: completionTriggerChar, - query: completionQuery, - items: completionItems, - closeCompletion, - openCompletion, - refreshCompletion, - } = completion; - - const contextUsage = useMemo( - () => computeContextUsage(usageStats, modelInfo), - [usageStats, modelInfo], - ); - - // Track a lightweight signature of workspace files to detect content changes even when length is unchanged - const workspaceFilesSignature = useMemo( - () => - fileContext.workspaceFiles - .map( - (file) => - `${file.id}|${file.label}|${file.description ?? ''}|${file.path}`, - ) - .join('||'), - [fileContext.workspaceFiles], - ); - - // When workspace files update while menu open for @, refresh items to reflect latest search results. - // Note: Avoid depending on the entire `completion` object here, since its identity - // changes on every render which would retrigger this effect and can cause a refresh loop. - useEffect(() => { - if (completionIsOpen && completionTriggerChar === '@') { - // Only refresh items; do not change other completion state to avoid re-renders loops - refreshCompletion(); - } - }, [ - workspaceFilesSignature, - completionIsOpen, - completionTriggerChar, - completionQuery, - refreshCompletion, - ]); - - useEffect(() => { - if ( - completionIsOpen && - completionTriggerChar === '/' && - isSkillsSecondaryQuery(completionQuery) - ) { - refreshCompletion(); - } - }, [ - availableSkills, - completionIsOpen, - completionTriggerChar, - completionQuery, - refreshCompletion, - ]); - - const { attachedImages, handleRemoveImage, clearImages, handlePaste } = - useImagePaste({ - onError: (error) => { - console.error('Paste error:', error); - }, - }); - - const { handleSubmit: submitMessage } = useMessageSubmit({ - inputText, - setInputText, - attachedImages, - clearImages, - messageHandling, - fileContext, - skipAutoActiveContext, - vscode, - inputFieldRef, - isStreaming: messageHandling.isStreaming, - isWaitingForResponse: messageHandling.isWaitingForResponse, - }); - - const canSubmit = shouldSendMessage({ - inputText, - attachedImages, - isStreaming: messageHandling.isStreaming, - isWaitingForResponse: messageHandling.isWaitingForResponse, - }); - - // Handle cancel/stop from the input bar - // Emit a cancel to the extension and immediately reflect interruption locally. - const handleCancel = useCallback(() => { - if (!messageHandling.isStreaming && !messageHandling.isWaitingForResponse) { - const inputElement = inputFieldRef.current; - if (inputElement) { - const text = stripZeroWidthSpaces(inputElement.textContent ?? ''); - setInputText(text); - inputElement.setAttribute( - 'data-empty', - text.trim().length === 0 ? 'true' : 'false', - ); - inputElement.blur(); - } - return; - } - - if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) { - // End streaming state and add an 'Interrupted' line. - // IMPORTANT: Do NOT clear isWaitingForResponse here — let the - // extension's streamEnd message clear it after the cancel is - // properly processed on the backend. This keeps the submit - // guard active and prevents any cached input from being - // auto-submitted during the cancel → confirmed window. - if (messageHandling.isStreaming) { - try { - messageHandling.endStreaming?.(); - } catch { - /* no-op */ - } - messageHandling.addMessage({ - role: 'assistant', - content: 'Interrupted', - timestamp: Date.now(), - localOnly: true, - }); - } - } - // Notify extension/agent to cancel server-side work - vscode.postMessage({ - type: 'cancelStreaming', - data: {}, - }); - }, [inputFieldRef, messageHandling, setInputText, vscode]); - - // Message handling - useWebViewMessages({ - sessionManagement, - fileContext, - messageHandling, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - setPlanEntries, - handlePermissionRequest: setPermissionRequest, - handleAskUserQuestion: setAskUserQuestionRequest, - inputFieldRef, - setInputText, - setEditMode, - setIsAuthenticated, - setUsageStats: (stats) => setUsageStats(stats ?? null), - setModelInfo: (info) => { - setModelInfo(info); - }, - setAvailableCommands: (commands) => { - setAvailableCommands(commands); - }, - setAvailableSkills: (skills) => { - setAvailableSkills(skills); - }, - setAvailableModels: (models) => { - setAvailableModels(models); - }, - setAccountInfo: (info) => { - setAccountInfo(info); - }, - setInsightProgress: (progress) => { - setInsightProgress(progress); - }, - setInsightReportPath: (path) => { - setInsightReportPath(path); - }, - }); - - // Set loading state to false after initial mount and when we have authentication info - useEffect(() => { - if (isAuthenticated !== null) { - setIsLoading(false); - return; - } - - // Safety-net timeout: if initialization takes too long (e.g. CLI crashed - // before the error could be surfaced), stop the spinner and let the user - // see the onboarding / error UI instead of hanging forever. - const timeout = setTimeout(() => { - setIsLoading(false); - }, 30_000); - return () => clearTimeout(timeout); - }, [isAuthenticated]); - - // Single source of truth for "a fixed overlay layer is mounted". Besides - // the modal dialogs (PermissionDrawer / AskUserQuestionDialog / - // AccountInfoDialog) this includes webui's SessionSelector, whose backdrop - // and dropdown are also fixed z-[999]/z-[1000] layers. Both the - // close-effect below and the /model open gate must use this one predicate: - // the selector paints beneath every one of these layers, so the two must - // never be mounted together in either direction. Keeping the check in one - // place is what prevents the two call sites from drifting apart. - const isOverlayActive = Boolean( - permissionRequest || - askUserQuestionRequest || - // accountInfo doubles as the AccountInfoDialog visibility flag: it is - // only set by the on-demand accountInfo message and reset by the - // dialog's onClose, so truthy here means "the dialog is up". - accountInfo || - sessionManagement.showSessionSelector, - ); - - // Close the model selector when an overlay takes over: while open the - // selector consumes Enter/Escape/arrow keys via a capture-phase document - // listener, and since it paints below the overlays those keystrokes must - // reach the visible overlay instead. The /model open path is gated on the - // same isOverlayActive predicate. - // - // useLayoutEffect, not useEffect: the close must land inside the same - // commit as the overlay's arrival. A passive effect would leave one commit - // in which the overlay and the selector are mounted together, with the - // selector's capture-phase keydown listener still armed beneath the - // visible overlay — an Enter in that window silently switches the model - // instead of answering the overlay. - React.useLayoutEffect(() => { - if (showModelSelector && isOverlayActive) { - setShowModelSelector(false); - } - }, [showModelSelector, isOverlayActive]); - - // Close the model selector on session takeover: the selector belongs to the - // previous conversation, and a session switch/load/clear is an - // overlay-equivalent takeover. A non-gesture takeover (e.g. - // conversationLoaded on startup/reconnect) fires no mousedown, so the - // outside-click close never runs and a stale selector would keep owning - // Enter/Escape/arrows over the new conversation. - useEffect(() => { - const closeSelectorOnSessionTakeover = (event: MessageEvent) => { - const message = event.data as { type?: string } | undefined; - if ( - message?.type === 'conversationLoaded' || - message?.type === 'qwenSessionSwitched' || - message?.type === 'conversationCleared' - ) { - setShowModelSelector(false); - } - }; - window.addEventListener('message', closeSelectorOnSessionTakeover); - return () => - window.removeEventListener('message', closeSelectorOnSessionTakeover); - }, []); - - // Handle permission response - const handlePermissionResponse = useCallback( - (optionId: string) => { - // Forward the selected optionId directly to extension as ACP permission response - // Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc. - vscode.postMessage({ - type: 'permissionResponse', - data: { optionId }, - }); - - setPermissionRequest(null); - }, - [vscode], - ); - - // Handle ask user question response - const handleAskUserQuestionResponse = useCallback( - (answers: Record) => { - // Forward answers to extension as ACP permission response - vscode.postMessage({ - type: 'askUserQuestionResponse', - data: { answers }, - }); - - setAskUserQuestionRequest(null); - }, - [vscode], - ); - - // Handle ask user question cancel - const handleAskUserQuestionCancel = useCallback(() => { - // Forward cancel to extension as ACP permission response with cancel option - vscode.postMessage({ - type: 'askUserQuestionResponse', - data: { answers: {}, cancelled: true }, - }); - - setAskUserQuestionRequest(null); - }, [vscode]); - - // Handle completion selection. - // When fillOnly is true (Tab), slash commands are inserted into the input - // instead of being sent immediately, so users can append arguments. - const handleCompletionSelect = useCallback( - (item: CompletionItem, fillOnly?: boolean) => { - // Handle completion selection by inserting the value into the input field - const inputElement = inputFieldRef.current; - if (!inputElement) { - return; - } - - // Ignore info items (placeholders like "Searching files…") - if (item.type === 'info') { - closeCompletion(); - return; - } - - // Commands can execute immediately - if (item.type === 'command') { - const itemId = item.id; - - // Helper to clear trigger text from input - const clearTriggerText = () => { - const text = inputElement.textContent || ''; - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - // Fallback: just clear everything - inputElement.textContent = ''; - setInputText(''); - return; - } - - // Find and remove the slash command trigger - const range = selection.getRangeAt(0); - let cursorPos = text.length; - if (range.startContainer === inputElement) { - const childIndex = range.startOffset; - let offset = 0; - for ( - let i = 0; - i < childIndex && i < inputElement.childNodes.length; - i++ - ) { - offset += inputElement.childNodes[i].textContent?.length || 0; - } - cursorPos = offset || text.length; - } else if (range.startContainer.nodeType === Node.TEXT_NODE) { - const walker = document.createTreeWalker( - inputElement, - NodeFilter.SHOW_TEXT, - null, - ); - let offset = 0; - let found = false; - let node: Node | null = walker.nextNode(); - while (node) { - if (node === range.startContainer) { - offset += range.startOffset; - found = true; - break; - } - offset += node.textContent?.length || 0; - node = walker.nextNode(); - } - cursorPos = found ? offset : text.length; - } - - const textBeforeCursor = text.substring(0, cursorPos); - const slashPos = textBeforeCursor.lastIndexOf('/'); - if (slashPos >= 0) { - const newText = - text.substring(0, slashPos) + text.substring(cursorPos); - inputElement.textContent = newText; - setInputText(newText); - } - }; - - const clientActions: Record boolean> = { - auth: () => { - vscode.postMessage({ type: 'auth', data: {} }); - return true; - }, - account: () => { - vscode.postMessage({ type: 'getAccountInfo', data: {} }); - return true; - }, - model: () => { - if (isOverlayActive) { - return false; - } - setShowModelSelector(true); - return true; - }, - }; - - const clientAction = clientActions[itemId]; - if (clientAction) { - if (!clientAction()) { - return; - } - clearTriggerText(); - closeCompletion(); - return; - } - - // For server-provided slash commands, decide based on the `input` - // field: commands without input (input == null) auto-submit - // immediately; commands that accept input fall through to the generic - // insertion path so users can type arguments before submitting. - // Special case: /skills always uses fill behavior to allow the - // secondary skill picker to appear. - const serverCmd = availableCommands.find((c) => c.name === itemId); - const isSkillsCmd = shouldOpenSkillsSecondaryPicker( - item, - availableSkills, - ); - if ( - serverCmd && - !isSkillsCmd && - !isExpandableSlashCommand(serverCmd.name) - ) { - if (!serverCmd.input && !fillOnly) { - clearTriggerText(); - vscode.postMessage({ - type: 'sendMessage', - data: { text: `/${serverCmd.name}` }, - }); - closeCompletion(); - return; - } - // Command accepts input — fall through to fill the input box. - } - - // Handle secondary skill selection — send `/skills ` with - // optional trailing user text - if (itemId.startsWith(SKILL_ITEM_ID_PREFIX) && !fillOnly) { - clearTriggerText(); - const value = - typeof item.value === 'string' - ? item.value - : itemId.slice(SKILL_ITEM_ID_PREFIX.length); - vscode.postMessage({ - type: 'sendMessage', - data: { text: `/${value}` }, - }); - closeCompletion(); - return; - } - } - - // If selecting a file, add @filename -> fullpath mapping - if (item.type === 'file' && item.value && item.path) { - try { - fileContext.addFileReference(item.value, item.path); - } catch (err) { - console.warn('[App] addFileReference failed:', err); - } - } - - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - - // Current text and cursor — strip U+200B height placeholder so it - // does not contaminate the inserted completion text. - const rawText = inputElement.textContent || ''; - const text = stripZeroWidthSpaces(rawText); - const range = selection.getRangeAt(0); - - // Compute total text offset for contentEditable. The DOM offsets - // are based on rawText (which may contain U+200B), so we compute the - // raw cursor position first and then adjust for stripped characters. - let rawCursorPos = rawText.length; - if (range.startContainer === inputElement) { - const childIndex = range.startOffset; - let offset = 0; - for ( - let i = 0; - i < childIndex && i < inputElement.childNodes.length; - i++ - ) { - offset += inputElement.childNodes[i].textContent?.length || 0; - } - rawCursorPos = offset || rawText.length; - } else if (range.startContainer.nodeType === Node.TEXT_NODE) { - const walker = document.createTreeWalker( - inputElement, - NodeFilter.SHOW_TEXT, - null, - ); - let offset = 0; - let found = false; - let node: Node | null = walker.nextNode(); - while (node) { - if (node === range.startContainer) { - offset += range.startOffset; - found = true; - break; - } - offset += node.textContent?.length || 0; - node = walker.nextNode(); - } - rawCursorPos = found ? offset : rawText.length; - } - // Adjust cursor to match the stripped text by subtracting - // zero-width characters that appeared before the cursor. - const zeroWidthBeforeCursor = ( - rawText.substring(0, rawCursorPos).match(/\u200B/g) || [] - ).length; - const cursorPos = Math.max(0, rawCursorPos - zeroWidthBeforeCursor); - - // Replace from trigger to cursor with selected value - const textBeforeCursor = text.substring(0, cursorPos); - const atPos = textBeforeCursor.lastIndexOf('@'); - // Only consider slash as trigger if we're in slash command mode - const slashPos = - completionTriggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1; - const triggerPos = Math.max(atPos, slashPos); - - if (triggerPos >= 0) { - const insertValue = - typeof item.value === 'string' ? item.value : String(item.label); - const newText = - text.substring(0, triggerPos + 1) + // keep the trigger symbol - insertValue + - ' ' + - text.substring(cursorPos); - - // Update DOM and state, and move caret to end - inputElement.textContent = newText; - setInputText(newText); - - const newRange = document.createRange(); - const sel = window.getSelection(); - newRange.selectNodeContents(inputElement); - newRange.collapse(false); - sel?.removeAllRanges(); - sel?.addRange(newRange); - - if (shouldOpenSkillsSecondaryPicker(item, availableSkills)) { - const rangeRect = newRange.getBoundingClientRect(); - const inputRect = inputElement.getBoundingClientRect(); - const position = - rangeRect.top > 0 || rangeRect.left > 0 - ? { top: rangeRect.top, left: rangeRect.left } - : { top: inputRect.top, left: inputRect.left }; - - void openCompletion('/', `${insertValue} `, position); - return; - } - - if ( - completion.triggerChar === '/' && - isExpandableSlashCommand(insertValue.trim()) - ) { - completion.closeCompletion(); - requestAnimationFrame(() => { - inputElement.dispatchEvent(new Event('input', { bubbles: true })); - }); - return; - } - } - - // Close the completion menu - closeCompletion(); - }, - [ - availableCommands, - availableSkills, - closeCompletion, - completion, - completionTriggerChar, - fileContext, - inputFieldRef, - isOverlayActive, - openCompletion, - setInputText, - vscode, - ], - ); - - // Handle model selection - const handleModelSelect = useCallback( - (modelId: string) => { - vscode.postMessage({ - type: 'setModel', - data: { modelId }, - }); - }, - [vscode], - ); - - // Handle attach context click - const handleAttachContextClick = useCallback(() => { - // Open native file picker (different from '@' completion which searches workspace files) - vscode.postMessage({ - type: 'attachFile', - data: {}, - }); - }, [vscode]); - - // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) - const handleToggleEditMode = useCallback(() => { - setEditMode((prev) => { - const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; - - try { - vscode.postMessage({ - type: 'setApprovalMode', - data: { modeId: next }, - }); - } catch { - /* no-op */ - } - return next; - }); - }, [vscode]); - - // Handle Tab key to cycle approval modes when input is focused - const handleInputKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Tab' && !e.shiftKey && showModelSelector) { - e.preventDefault(); - return; - } - if ( - e.key === 'Tab' && - !e.shiftKey && - !isComposing && - !completion.isOpen && - !showModelSelector - ) { - e.preventDefault(); - handleToggleEditMode(); - } - }, - [completion.isOpen, handleToggleEditMode, isComposing, showModelSelector], - ); - - const handleToggleThinking = useCallback(() => { - setThinkingEnabled((prev) => !prev); - }, []); - - const hasContent = - messageHandling.messages.length > 0 || - messageHandling.isStreaming || - inProgressToolCalls.length > 0 || - completedToolCalls.length > 0 || - planEntries.length > 0; - - // Locally generated messages (connection/auth/generic errors and the - // "Interrupted" cancel mark) never flow through ACP `transcriptUpdate`, - // so the WebShell transcript cannot render them. Surface them in a - // notice slot above the composer instead of dropping them silently. - const localNotices = messageHandling.messages.filter( - (message) => message.localOnly, - ); - - // The WebShell transcript has no file-open callback; intercept file-like - // anchor clicks at the container level and route them through the - // existing `openFile` message (handled by FileMessageHandler), restoring - // the pre-PR behavior where file references opened in the editor. - const handleTranscriptClick = useCallback( - (event: React.MouseEvent) => { - const target = event.target as HTMLElement | null; - const anchor = target?.closest?.('a') as HTMLAnchorElement | null; - if (!anchor) { - return; - } - const filePath = resolveFileLinkFromAnchor(anchor); - if (!filePath) { - return; - } - event.preventDefault(); - event.stopPropagation(); - vscode.postMessage({ - type: 'openFile', - data: { path: filePath }, - }); - }, - [vscode], - ); - - // Open the generated /insight report in the editor via the extension's - // existing `openInsightReport` handler. - const handleOpenInsightReport = useCallback(() => { - if (!insightReportPath) { - return; - } - vscode.postMessage({ - type: 'openInsightReport', - data: { path: insightReportPath }, - }); - }, [insightReportPath, vscode]); - - // VS Code applies color-theme changes to an open webview in place - // (updating data-vscode-theme-kind on without reloading it), and - // the panel keeps its context while hidden, so a mount-time snapshot - // would go stale. Track the live value via a body-attribute observer. - const [webShellTheme, setWebShellTheme] = useState<'dark' | 'light'>( - readVSCodeWebviewTheme, - ); - useEffect(() => { - const observer = new MutationObserver(() => { - setWebShellTheme(readVSCodeWebviewTheme()); - }); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['data-vscode-theme-kind', 'class'], - }); - return () => observer.disconnect(); - }, []); - const transcriptBlocks = useAcpTranscript(); - - // === Contributed copy commands (qwen-code.copyMessage/copyAllMessages/ - // copyLastReply) === - // The extension host routes the commands back to the webview that last - // reported a context menu (`contextMenuTriggered`) via a `copyCommand` - // message. The transcript container carries the `webviewSection` context - // key the `webview/context` contributions filter on. - const messagesContainerRef = useRef(null); - const transcriptBlocksRef = useRef(transcriptBlocks); - useEffect(() => { - transcriptBlocksRef.current = transcriptBlocks; - }, [transcriptBlocks]); - const contextMenuRowKeyRef = useRef(null); - - useEffect(() => { - const trackTarget = (event: MouseEvent) => { - const container = messagesContainerRef.current; - const target = event.target instanceof Element ? event.target : null; - const row = target?.closest?.('[data-message-row-key]') as - | HTMLElement - | null - | undefined; - contextMenuRowKeyRef.current = - container && row && container.contains(row) - ? row.getAttribute('data-message-row-key') - : null; - // Notify the extension that this webview was right-clicked, so the - // contributed copy commands route here. - vscode.postMessage({ type: 'contextMenuTriggered', data: {} }); - }; - document.addEventListener('contextmenu', trackTarget, true); - return () => document.removeEventListener('contextmenu', trackTarget, true); - }, [vscode]); - - useEffect(() => { - const handleCopyCommand = (event: MessageEvent) => { - const message = event.data as { - type?: string; - data?: { action?: string }; - }; - if (message?.type !== 'copyCommand') { - return; - } - const blocks = transcriptBlocksRef.current; - let text: string | null = null; - if (message.data?.action === 'copyMessage') { - const block = findBlockByRowKey(blocks, contextMenuRowKeyRef.current); - text = block ? getBlockCopyText(block) : null; - } else if (message.data?.action === 'copyAllMessages') { - text = formatBlocksForCopyAll(blocks); - } else if (message.data?.action === 'copyLastReply') { - text = findLastAssistantText(blocks); - } - if (text) { - vscode.postMessage({ type: 'copyToClipboard', data: { text } }); - } - }; - window.addEventListener('message', handleCopyCommand); - return () => window.removeEventListener('message', handleCopyCommand); - }, [vscode]); - - return ( -
- {/* Top-level loading overlay */} - {(isLoading || sessionManagement.isSwitchingSession) && ( -
-
-
-

- {sessionManagement.isSwitchingSession - ? 'Loading conversation...' - : 'Preparing Qwen Code...'} -

-
-
- )} - - { - sessionManagement.handleSwitchSession(sessionId); - sessionManagement.setSessionSearchQuery(''); - }} - onRenameSession={sessionManagement.handleRenameSession} - onDeleteSession={sessionManagement.handleDeleteSession} - onClose={() => sessionManagement.setShowSessionSelector(false)} - hasMore={sessionManagement.hasMore} - isLoading={sessionManagement.isLoading} - onLoadMore={sessionManagement.handleLoadMoreSessions} - /> - - - sessionManagement.handleNewQwenSession(modelInfo?.modelId ?? null) - } - /> - -
- {!hasContent && !isLoading && !sessionManagement.isSwitchingSession ? ( - isAuthenticated === false ? ( - - ) : isAuthenticated === null ? ( -
- - - Preparing Qwen Code... - -
- ) : ( - - ) - ) : ( - - - -
- } - > - 0 - ? `${modelSelectorClearance + 8}px` - : '0px', - } as React.CSSProperties - } - /> - - - )} - {(localNotices.length > 0 || insightProgress || insightReportPath) && ( -
- {insightProgress && ( -
- -
- )} - {insightReportPath && ( - - )} - {localNotices.map((notice, index) => ( -
- {notice.content} -
- ))} -
- )} -
- - {isAuthenticated && ( - setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} - onKeyDown={handleInputKeyDown} - onSubmit={submitMessage} - onCancel={handleCancel} - onToggleEditMode={handleToggleEditMode} - onToggleThinking={handleToggleThinking} - onFocusActiveEditor={fileContext.focusActiveEditor} - onToggleSkipAutoActiveContext={() => - setSkipAutoActiveContext((v) => !v) - } - onShowCommandMenu={async () => { - if (inputFieldRef.current) { - inputFieldRef.current.focus(); - - const selection = window.getSelection(); - let position = { top: 0, left: 0 }; - - if (selection && selection.rangeCount > 0) { - try { - const range = selection.getRangeAt(0); - const rangeRect = range.getBoundingClientRect(); - if (rangeRect.top > 0 && rangeRect.left > 0) { - position = { - top: rangeRect.top, - left: rangeRect.left, - }; - } else { - const inputRect = - inputFieldRef.current.getBoundingClientRect(); - position = { top: inputRect.top, left: inputRect.left }; - } - } catch (error) { - console.error('[App] Error getting cursor position:', error); - const inputRect = - inputFieldRef.current.getBoundingClientRect(); - position = { top: inputRect.top, left: inputRect.left }; - } - } else { - const inputRect = inputFieldRef.current.getBoundingClientRect(); - position = { top: inputRect.top, left: inputRect.left }; - } - - await openCompletion('/', '', position); - } - }} - onAttachContext={handleAttachContextClick} - onPaste={handlePaste} - completionIsOpen={completionIsOpen} - completionItems={completionItems} - onCompletionSelect={handleCompletionSelect} - onCompletionFill={(item) => handleCompletionSelect(item, true)} - onCompletionClose={closeCompletion} - canSubmit={canSubmit} - extraContent={ - attachedImages.length > 0 ? ( - - ) : null - } - showModelSelector={showModelSelector} - availableModels={availableModels} - currentModelId={modelInfo?.modelId} - onSelectModel={handleModelSelect} - onCloseModelSelector={() => setShowModelSelector(false)} - onModelSelectorClearance={handleModelSelectorClearanceChange} - /> - )} - - {isAuthenticated && permissionRequest && ( - setPermissionRequest(null)} - /> - )} - - {isAuthenticated && askUserQuestionRequest && ( - - )} - - {accountInfo && ( - setAccountInfo(null)} - /> - )} -
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx new file mode 100644 index 00000000000..4f92dc6594d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx @@ -0,0 +1,610 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import type { ComponentType, ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let EmbeddedApp: ComponentType; + +interface CapturedProps { + [key: string]: unknown; +} + +interface RenderedApp { + container: HTMLElement; + root: Root; +} + +const mocks = vi.hoisted(() => ({ + vscode: { + postMessage: vi.fn(), + getState: vi.fn(() => ({})), + setState: vi.fn(), + }, + embeddedProps: { current: null as CapturedProps | null }, + connectionError: { current: undefined as string | undefined }, + errorNotifications: { current: 0 }, +})); + +const sdkMocks = vi.hoisted(() => ({ + listWorkspaceSessionsPage: vi.fn(), +})); + +vi.mock('@qwen-code/sdk/daemon', () => ({ + DaemonClient: class { + workspaceByCwd() { + return { + listWorkspaceSessionsPage: sdkMocks.listWorkspaceSessionsPage, + updateSessionMetadata: vi.fn(async () => ({})), + deleteSessionsData: vi.fn(async () => ({})), + }; + } + getRewindSnapshots = vi.fn(async () => ({ snapshots: [] })); + rewindSession = vi.fn(async () => ({})); + }, +})); + +vi.mock('@qwen-code/web-shell', async () => { + const { useEffect, useMemo, useRef, useState } = await import('react'); + return { + WebShellWithProviders: (props: CapturedProps) => { + mocks.embeddedProps.current = props; + // Mirror App.tsx's error-notification effect: while a connection error + // persists, each distinct error value is reported once. Hosts may pass + // an onError whose identity changes on every render, which re-runs the + // effect without re-delivering the already-reported error. + const onError = props.onError as ((error: Error) => void) | undefined; + const lastReportedError = useRef(undefined); + const [churn, setChurn] = useState(0); + // A fresh wrapper identity whenever the host's onError identity + // changes mirrors a host passing an inline onError; the churn state + // below additionally forces the effect to re-run after a delivery, + // like the host re-render that delivering the error triggers. + const unstableOnError = useMemo( + () => (onError ? (error: Error) => onError(error) : undefined), + [onError], + ); + useEffect(() => { + const message = mocks.connectionError.current; + if (!message) { + lastReportedError.current = undefined; + return; + } + if (lastReportedError.current === message) return; + // App.tsx returns before stamping when no handler is attached, so a + // handler that appears later still receives the persistent error. + if (!unstableOnError) return; + lastReportedError.current = message; + mocks.errorNotifications.current += 1; + if (mocks.errorNotifications.current > 3) { + // Value-dedup makes a notify loop impossible; fail fast if this + // mirror ever regresses instead of hanging. + throw new Error('onError notified in a loop'); + } + unstableOnError(new Error(message)); + // Delivering an error re-renders the host; force one extra effect + // run under a fresh callback identity to mirror that churn. + if (churn < 1) setChurn((count) => count + 1); + }, [unstableOnError, churn]); + return null; + }, + }; +}); + +vi.mock('./hooks/useVSCode.js', () => ({ + useVSCode: () => mocks.vscode, +})); + +const mounted: RenderedApp[] = []; + +async function renderApp(): Promise { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + mounted.push({ container, root }); + const props = mocks.embeddedProps.current; + expect(props).not.toBeNull(); + return props as CapturedProps; +} + +function callback unknown>( + props: CapturedProps, + name: string, +): T { + const value = props[name]; + expect(typeof value).toBe('function'); + return value as T; +} + +function postMessagesOfType( + type: string, +): Array<{ type?: string; data?: unknown }> { + return mocks.vscode.postMessage.mock.calls + .map(([message]) => message as { type?: string }) + .filter((message) => message.type === type); +} + +beforeAll(async () => { + document.body.dataset.qwenDaemonBaseUrl = 'http://localhost:4141'; + document.body.dataset.qwenWorkspaceCwd = '/workspace'; + document.body.dataset.qwenSessionId = 'session-1'; + document.body.dataset.qwenHostKind = 'panel'; + ({ EmbeddedApp } = await import('./EmbeddedApp.js')); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.embeddedProps.current = null; + mocks.connectionError.current = undefined; + mocks.errorNotifications.current = 0; +}); + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +describe('EmbeddedApp host wiring', () => { + it('attributes its sessions to the VS Code channel', async () => { + const props = await renderApp(); + // The daemon is shared with the CLI and the browser Web Shell for this + // workspace; without a distinct source type the panel cannot tell its own + // conversations apart from theirs. + expect(props['sessionSourceType']).toBe('vscode'); + }); + + it('injects the active editor reference into prepared submissions', async () => { + await renderApp(); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'activeEditorChanged', + data: { + fileName: 'editor.ts', + filePath: '/workspace/editor.ts', + selection: { startLine: 3, endLine: 5 }, + }, + }, + }), + ); + await Promise.resolve(); + }); + + const prepareSubmit = callback< + (submission: { + prompt: string; + sessionId?: string; + inputAnnotations: unknown[]; + }) => Promise<{ prompt: string; inputAnnotations: unknown[] } | undefined> + >(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit'); + + await expect( + prepareSubmit({ prompt: 'Explain this', inputAnnotations: [] }), + ).resolves.toEqual({ + prompt: '@editor.ts (selected lines 3-5) Explain this', + inputAnnotations: [ + expect.objectContaining({ + type: 'reference', + start: 0, + end: '@editor.ts'.length, + reference: expect.objectContaining({ + kind: 'file', + label: 'editor.ts', + value: '/workspace/editor.ts', + }), + }), + ], + }); + }); + + it('keeps an authenticated session visible when auth is cancelled', async () => { + await renderApp(); + const { container } = mounted[mounted.length - 1]; + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: true } }, + }), + ); + await Promise.resolve(); + }); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { data: { type: 'authCancelled' } }), + ); + await Promise.resolve(); + }); + + // The live session must not be swapped for the onboarding screen. + expect(container.textContent).not.toContain('Get Started'); + }); + + it('still shows onboarding when an unauthenticated flow is cancelled', async () => { + await renderApp(); + const { container } = mounted[mounted.length - 1]; + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: false } }, + }), + ); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Get Started'); + }); + + it('keeps an explicit active-file exclusion across same-file editor changes', async () => { + await renderApp(); + + const dispatchEditorChanged = (fileName: string, filePath: string) => + act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'activeEditorChanged', + data: { fileName, filePath }, + }, + }), + ); + await Promise.resolve(); + }); + + await dispatchEditorChanged('editor.ts', '/workspace/editor.ts'); + + // The composer chip lives in a render prop consumed by the (mocked) + // shell, so render it standalone to click it. + const renderToolbar = callback< + (args: { disabled: boolean; currentModel?: string }) => ReactNode + >( + mocks.embeddedProps.current as CapturedProps, + 'renderComposerToolbarStart', + ); + const toolbarContainer = document.createElement('div'); + document.body.appendChild(toolbarContainer); + const toolbarRoot = createRoot(toolbarContainer); + + try { + await act(async () => { + toolbarRoot.render( + renderToolbar({ disabled: false, currentModel: 'm' }), + ); + await Promise.resolve(); + }); + const chip = toolbarContainer.querySelector('.qwen-vscode-active-file'); + if (!chip) throw new Error('active-file chip did not render'); + await act(async () => { + chip.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + // A selection-only change for the same file must not re-arm inclusion. + await dispatchEditorChanged('editor.ts', '/workspace/editor.ts'); + const prepareSubmitAfterSameFile = callback< + (submission: { + prompt: string; + inputAnnotations: unknown[]; + }) => Promise< + { prompt: string; inputAnnotations: unknown[] } | undefined + > + >(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit'); + await expect( + prepareSubmitAfterSameFile({ prompt: 'hi', inputAnnotations: [] }), + ).resolves.toBeUndefined(); + + // Switching to a different file re-arms inclusion. + await dispatchEditorChanged('other.ts', '/workspace/other.ts'); + const prepareSubmitAfterSwitch = callback< + (submission: { + prompt: string; + inputAnnotations: unknown[]; + }) => Promise< + { prompt: string; inputAnnotations: unknown[] } | undefined + > + >(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit'); + await expect( + prepareSubmitAfterSwitch({ prompt: 'hi', inputAnnotations: [] }), + ).resolves.toMatchObject({ prompt: '@other.ts hi' }); + } finally { + act(() => toolbarRoot.unmount()); + toolbarContainer.remove(); + } + }); + + it('treats a workspace-relative mention annotation as already included', async () => { + await renderApp(); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'activeEditorChanged', + data: { fileName: 'editor.ts', filePath: '/workspace/editor.ts' }, + }, + }), + ); + await Promise.resolve(); + }); + + const prepareSubmit = callback< + (submission: { + prompt: string; + inputAnnotations: unknown[]; + }) => Promise<{ prompt: string; inputAnnotations: unknown[] } | undefined> + >(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit'); + + const mention = { + type: 'reference', + start: 8, + end: 18, + text: '@editor.ts', + reference: { + id: 'mention-1', + kind: 'file', + label: 'editor.ts', + value: 'editor.ts', + serialized: '@editor.ts', + }, + }; + + await expect( + prepareSubmit({ + prompt: 'Explain @editor.ts', + inputAnnotations: [mention], + }), + ).resolves.toEqual({ + prompt: 'Explain @editor.ts', + inputAnnotations: [expect.objectContaining({ start: 8, end: 18 })], + }); + }); + + it('matches typed active-file references on a whole-reference boundary', async () => { + await renderApp(); + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'activeEditorChanged', + data: { fileName: 'editor.ts', filePath: '/workspace/editor.ts' }, + }, + }), + ); + await Promise.resolve(); + }); + + const prepareSubmit = callback< + (submission: { + prompt: string; + inputAnnotations: unknown[]; + }) => Promise<{ prompt: string; inputAnnotations: unknown[] } | undefined> + >(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit'); + + // A sibling-file mention must not suppress the active-file injection. + await expect( + prepareSubmit({ prompt: '@editor.tsx hi', inputAnnotations: [] }), + ).resolves.toMatchObject({ prompt: '@editor.ts @editor.tsx hi' }); + + // An exact mention is recognized and annotated, not duplicated. + const prepared = await prepareSubmit({ + prompt: '@editor.ts hi', + inputAnnotations: [], + }); + expect(prepared).toMatchObject({ prompt: '@editor.ts hi' }); + expect(prepared?.inputAnnotations).toHaveLength(1); + expect(prepared?.inputAnnotations[0]).toMatchObject({ + start: 0, + end: '@editor.ts'.length, + }); + }); + + it('opens permission diffs only from authoritative tool-call content', async () => { + const props = await renderApp(); + const onTranscriptChange = callback<(blocks: unknown[]) => void>( + props, + 'onTranscriptChange', + ); + + await act(async () => { + onTranscriptChange([ + { + id: 'perm-write', + kind: 'permission', + requestId: 'req-write', + title: 'Write new.ts', + options: [], + preview: { kind: 'key_value', rows: [] }, + toolCall: { + content: [ + { + type: 'diff', + path: '/workspace/new.ts', + oldText: 'header\nconst value = 1;\nfooter', + newText: 'header\nconst value = 2;\nfooter', + }, + ], + }, + }, + { + id: 'perm-mined', + kind: 'permission', + requestId: 'req-mined', + title: 'update a.txt', + options: [], + preview: { kind: 'key_value', rows: [] }, + toolCall: { + _meta: { toolName: 'edit_file' }, + file_path: 'a.txt', + original_content: 'X', + new_content: 'Y', + }, + }, + ]); + await Promise.resolve(); + }); + + const openDiffs = postMessagesOfType('openDiff'); + expect(openDiffs).toHaveLength(1); + expect(openDiffs[0]).toEqual({ + type: 'openDiff', + data: { + path: '/workspace/new.ts', + oldText: 'header\nconst value = 1;\nfooter', + newText: 'header\nconst value = 2;\nfooter', + source: 'web-shell', + }, + }); + }); + + it('routes auth and session-change host actions to the extension', async () => { + const props = await renderApp(); + + const onSlashCommand = callback< + (command: { command: string; input: string }) => boolean | void + >(props, 'onSlashCommand'); + expect(onSlashCommand({ command: 'auth', input: '' })).toBe(true); + expect(onSlashCommand({ command: 'account', input: '' })).toBe(true); + + callback<(sessionId: string | undefined) => void>( + props, + 'onSessionIdChange', + )('session-2'); + callback<(session: { sessionId?: string; sessionName?: string }) => void>( + props, + 'onSessionInfoChange', + )({ sessionId: 'session-2', sessionName: 'My Title' }); + + expect(postMessagesOfType('auth')).toHaveLength(1); + expect(postMessagesOfType('getAccountInfo')).toHaveLength(1); + expect(postMessagesOfType('webShellSessionChanged').at(-1)).toEqual({ + type: 'webShellSessionChanged', + data: { sessionId: 'session-2', workspaceCwd: '/workspace' }, + }); + expect(postMessagesOfType('updatePanelTitle').at(-1)).toEqual({ + type: 'updatePanelTitle', + data: { title: 'My Title' }, + }); + }); + + it('notifies once when a connection error persists instead of looping', async () => { + mocks.connectionError.current = 'daemon connection lost'; + + // The mirrored effect re-runs under a fresh onError identity on every + // re-render (like a host passing an inline onError); the value-dedup + // must still deliver the persistent error exactly once. The mock trips + // after three notifications instead of hanging if that ever regresses. + await renderApp(); + const { container } = mounted[mounted.length - 1]; + + expect(mocks.errorNotifications.current).toBe(1); + const alerts = container.querySelectorAll('[role="alert"]'); + expect(alerts).toHaveLength(1); + expect(alerts[0].textContent).toContain('daemon connection lost'); + }); + + it('does not report or stamp an error while no onError handler is attached', async () => { + mocks.connectionError.current = 'daemon connection lost'; + const { WebShellWithProviders } = await import('@qwen-code/web-shell'); + const WebShell = + WebShellWithProviders as unknown as ComponentType; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ container, root }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + // App.tsx returns before stamping when no handler exists; the mirror must + // leave the error unreported and unstamped here. + expect(mocks.errorNotifications.current).toBe(0); + + // Because nothing was stamped, a handler attached later still receives + // the persistent error exactly once. + await act(async () => { + root.render( {}} />); + await Promise.resolve(); + }); + expect(mocks.errorNotifications.current).toBe(1); + }); + + it('releases the panel when a session switch times out', async () => { + sdkMocks.listWorkspaceSessionsPage.mockResolvedValueOnce({ + sessions: [ + { + sessionId: 'session-2', + workspaceCwd: '/workspace', + displayName: 'Other session', + }, + ], + nextCursor: undefined, + }); + vi.useFakeTimers(); + try { + await renderApp(); + const { container } = mounted[mounted.length - 1]; + + const historyButton = container.querySelector( + 'button[aria-haspopup="dialog"]', + ) as HTMLButtonElement; + expect(historyButton).not.toBeNull(); + await act(async () => { + historyButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const row = document.querySelector( + '[data-session-id="session-2"]', + ) as HTMLElement; + expect(row).not.toBeNull(); + await act(async () => { + row.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Loading conversation…'); + + // A retriable connection failure that never settles must not lock the + // panel behind the overlay forever. + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(container.textContent).not.toContain('Loading conversation…'); + expect(container.textContent).toContain( + 'The conversation switch timed out. Try again.', + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx new file mode 100644 index 00000000000..6deddaa4cfe --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx @@ -0,0 +1,1775 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { + WebShellWithProviders, + type ComposerToolbarAction, + type TurnOutputOpenRequest, + type WebShellApi, + type WebShellComposerApi, + type WebShellTheme, +} from '@qwen-code/web-shell'; +import { + DaemonClient, + type DaemonSessionSummary, + type DaemonTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import { ChevronDown, FileText, LoaderCircle, Plus, X } from 'lucide-react'; +import { useVSCode } from './hooks/useVSCode.js'; +import { QwenOnboarding } from './components/QwenOnboarding.js'; +import { SessionHistoryDropdown } from './components/SessionHistoryDropdown.js'; +import { + createChromeStrings, + readLanguage, + type ChromeStrings, +} from './strings.js'; +import { VSCODE_SESSION_SOURCE_TYPE } from './sessionSource.js'; +import { + findBlockByRowKey, + findLastAssistantText, + formatBlocksForCopyAll, + getBlockCopyText, +} from './utils/copyTranscript.js'; +import { resolveFileLinkFromAnchor } from './utils/fileLinks.js'; +import { isDiscontinuedModel } from './utils/discontinuedModel.js'; + +const SESSION_SWITCH_TIMEOUT_MS = 15_000; +const SESSION_SWITCH_MIN_VISIBLE_MS = 120; + +const COMPOSER_TOOLBAR_ACTIONS = [ + 'approvalMode', + 'contextUsage', + 'model', +] as const satisfies readonly ComposerToolbarAction[]; + +const isVsCodeModelVisible = (model: { id: string }) => + !isDiscontinuedModel(model.id); + +/** Host-only slash entries. Built per language so the menu is not half-English. */ +function buildVsCodeSlashCommands(t: ChromeStrings) { + return [ + { + name: 'model', + description: t('cmd.model.description'), + completionLabel: t('cmd.model.label'), + completionSection: t('cmd.section.model'), + completionPriority: -110, + autoSubmit: true, + }, + { + name: 'auth', + description: t('cmd.auth.description'), + completionSection: t('cmd.section.account'), + completionPriority: -100, + autoSubmit: true, + }, + { + name: 'account', + description: t('cmd.account.description'), + completionLabel: t('cmd.account.label'), + completionSection: t('cmd.section.account'), + completionPriority: -100, + autoSubmit: true, + }, + { + name: 'export', + description: t('cmd.export.description'), + completionSection: t('cmd.section.session'), + completionPriority: -90, + subcommands: ['html', 'md', 'json', 'jsonl'], + }, + ]; +} + +const VSCODE_HIDDEN_SLASH_COMMANDS = [ + 'theme', + 'language', + 'settings', + 'release', + 'schedule', + 'extensions', + 'workspace', + 'fork', + 'branch', + 'diff', + 'log', + 'prs', + 'new', + 'clear', + 'reset', + 'rename', + 'resume', + 'agents', + 'tasks', +] as const; + +const ROOT_STYLE: CSSProperties = { + display: 'flex', + flex: '1 1 auto', + width: '100%', + height: '100%', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', +}; + +const VSCODE_THEME_STYLE = { + '--font-sans': + 'var(--vscode-chat-font-family, var(--vscode-font-family, system-ui, sans-serif))', + '--font-mono': + 'var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, monospace)', + '--background': 'var(--vscode-sideBar-background)', + '--foreground': 'var(--vscode-foreground)', + '--card': 'var(--vscode-editorWidget-background)', + '--card-foreground': 'var(--vscode-editorWidget-foreground)', + '--popover': 'var(--vscode-dropdown-background)', + '--popover-foreground': 'var(--vscode-dropdown-foreground)', + '--primary': 'var(--vscode-button-background)', + '--primary-foreground': 'var(--vscode-button-foreground)', + '--secondary': 'var(--vscode-input-background)', + '--secondary-foreground': 'var(--vscode-descriptionForeground)', + '--muted': 'var(--vscode-sideBarSectionHeader-background)', + '--muted-foreground': 'var(--vscode-descriptionForeground)', + '--accent': 'var(--vscode-list-hoverBackground)', + '--accent-foreground': 'var(--vscode-list-hoverForeground)', + '--border': 'var(--vscode-widget-border, var(--vscode-panel-border))', + '--ring': 'var(--vscode-focusBorder)', + '--destructive': 'var(--vscode-errorForeground)', + '--error-border': 'var(--vscode-inputValidation-errorBorder)', + '--chat-editor-bg-primary': 'var(--vscode-input-background)', + '--chat-editor-bg-tertiary': 'var(--vscode-toolbar-hoverBackground)', + '--chat-editor-border-color': + 'var(--vscode-input-border, var(--vscode-widget-border))', + '--chat-editor-text-primary': 'var(--vscode-input-foreground)', + '--chat-editor-text-secondary': 'var(--vscode-descriptionForeground)', + '--chat-editor-text-dimmed': 'var(--vscode-input-placeholderForeground)', + '--chat-editor-accent-color': 'var(--vscode-focusBorder)', + '--agent-gray-200': 'var(--vscode-input-border, var(--vscode-widget-border))', + '--agent-gray-500': 'var(--vscode-descriptionForeground)', + '--success-color': 'var(--vscode-testing-iconPassed, #89d185)', + '--warning-color': 'var(--vscode-editorWarning-foreground, #cca700)', + '--error-color': 'var(--vscode-errorForeground, #f48771)', + '--scrollbar-thumb': 'var(--vscode-scrollbarSlider-background)', + '--scrollbar-thumb-hover': 'var(--vscode-scrollbarSlider-hoverBackground)', + '--scrollbar-track': 'transparent', +} as CSSProperties; + +const SHELL_STYLE: CSSProperties = { + ...ROOT_STYLE, + ...VSCODE_THEME_STYLE, + height: '100%', +}; + +const VSCODE_EMBEDDED_CSS = ` + @keyframes qwen-vscode-spin { to { transform: rotate(360deg); } } + .qwen-vscode-header-button:hover, + .qwen-vscode-header-button:focus-visible, + .qwen-vscode-toolbar-button:hover, + .qwen-vscode-toolbar-button:focus-visible { + background: var(--vscode-toolbar-hoverBackground); + outline: none; + } + .qwen-vscode-toolbar-start { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 2px; + } + .qwen-vscode-active-file-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + @media (max-width: 380px) { + .qwen-vscode-active-file { + width: 28px !important; + max-width: 28px !important; + padding: 0 6px !important; + } + .qwen-vscode-active-file-label { display: none; } + } +`; + +function readTheme(): WebShellTheme { + const kind = document.body.getAttribute('data-vscode-theme-kind') ?? ''; + return /light/i.test(kind) ? 'light' : 'dark'; +} + +interface RuntimeConfig { + baseUrl: string; + token?: string; + clientId?: string; + workspaceCwd?: string; + sessionId?: string; + hostKind?: 'view' | 'panel'; +} + +function readRuntimeConfig(): RuntimeConfig | null { + const baseUrl = document.body.dataset.qwenDaemonBaseUrl; + if (!baseUrl) return null; + return { + baseUrl, + token: document.body.dataset.qwenDaemonToken || undefined, + clientId: document.body.dataset.qwenDaemonClientId || undefined, + workspaceCwd: document.body.dataset.qwenWorkspaceCwd || undefined, + sessionId: document.body.dataset.qwenSessionId || undefined, + hostKind: + document.body.dataset.qwenHostKind === 'panel' + ? 'panel' + : document.body.dataset.qwenHostKind === 'view' + ? 'view' + : undefined, + }; +} + +interface ActiveFileContext { + fileName: string; + filePath: string; + selection?: { startLine: number; endLine: number }; +} + +interface HostNotice { + tone: 'info' | 'error'; + text: string; + action?: { label: string; path: string }; +} + +interface AccountInfo { + authType?: string | null; + baseUrl?: string | null; + envKey?: string | null; + modelId?: string | null; + error?: string; +} + +interface InsightProgress { + stage: string; + progress: number; + detail?: string; +} + +interface EditingMessage { + turnIndex?: number; +} + +function isAutomaticApprovalMode(modeId: unknown): boolean { + return modeId === 'auto-edit' || modeId === 'yolo'; +} + +interface PermissionDiffPreview { + path: string; + oldText: string; + newText: string; +} + +function permissionDiffPreview( + block: Extract, +): PermissionDiffPreview | undefined { + const toolCall = block.toolCall; + if (!toolCall || typeof toolCall !== 'object') return undefined; + const content = (toolCall as { content?: unknown }).content; + if (!Array.isArray(content)) return undefined; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const diff = item as Record; + if ( + diff.type === 'diff' && + typeof diff.path === 'string' && + (typeof diff.oldText === 'string' || typeof diff.newText === 'string') + ) { + return { + path: diff.path, + oldText: typeof diff.oldText === 'string' ? diff.oldText : '', + newText: typeof diff.newText === 'string' ? diff.newText : '', + }; + } + } + return undefined; +} + +export function EmbeddedApp() { + const vscode = useVSCode(); + const language = useMemo(readLanguage, []); + const t = useMemo(() => createChromeStrings(language), [language]); + const slashCommands = useMemo(() => buildVsCodeSlashCommands(t), [t]); + const [theme, setTheme] = useState(readTheme); + const initialRuntime = useMemo(readRuntimeConfig, []); + const [runtime, setRuntime] = useState(initialRuntime); + const runtimeRef = useRef(runtime); + runtimeRef.current = runtime; + const [runtimeError, setRuntimeError] = useState(); + const [authenticated, setAuthenticated] = useState(null); + const [authConnecting, setAuthConnecting] = useState(false); + const [authError, setAuthError] = useState(); + const [hostNotice, setHostNotice] = useState(); + const [accountInfo, setAccountInfo] = useState(); + const [insightProgress, setInsightProgress] = useState(); + const [insightReportPath, setInsightReportPath] = useState(); + const [activeFile, setActiveFile] = useState(); + const [includeActiveFile, setIncludeActiveFile] = useState(true); + const [sessionTitle, setSessionTitle] = useState(() => t('session.new')); + const [sessionHistoryOpen, setSessionHistoryOpen] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [sessions, setSessions] = useState([]); + const [sessionCursor, setSessionCursor] = useState(); + const [sessionListLoading, setSessionListLoading] = useState(false); + const [sessionListError, setSessionListError] = useState(); + const [switchingSessionId, setSwitchingSessionId] = useState(); + const [creatingSession, setCreatingSession] = useState(false); + const [editingMessage, setEditingMessage] = useState(); + const latestSubmittedPromptRef = useRef<{ + sessionId: string; + prompt: string; + } | undefined>(undefined); + const sessionSwitchStartedAtRef = useRef(0); + const sessionSwitchTimerRef = useRef< + ReturnType | undefined + >(undefined); + const historyButtonRef = useRef(null); + const shellRef = useRef(null); + const composerRef = useRef(null); + const currentModelIdRef = useRef(undefined); + const transcriptBlocksRef = useRef([]); + const openPermissionDiffsRef = useRef(new Map()); + const focusedPermissionRequestIdRef = useRef(undefined); + const contextMenuRowKeyRef = useRef(null); + const previousActiveFilePathRef = useRef(undefined); + const daemonBaseUrl = runtime?.baseUrl; + const daemonToken = runtime?.token; + const daemonClient = useMemo( + () => + daemonBaseUrl + ? new DaemonClient({ baseUrl: daemonBaseUrl, token: daemonToken }) + : null, + [daemonBaseUrl, daemonToken], + ); + + const clearInsight = useCallback(() => { + setInsightProgress(undefined); + setInsightReportPath(undefined); + }, []); + + const cancelMessageEditing = useCallback(() => { + setEditingMessage(undefined); + composerRef.current?.clear({ text: true, tags: true }); + composerRef.current?.focus?.(); + }, []); + + useEffect( + () => () => { + if (sessionSwitchTimerRef.current) { + clearTimeout(sessionSwitchTimerRef.current); + } + }, + [], + ); + + // Web Shell reports each distinct connection error value only once, so a + // new identity here (e.g. when `t` is rebuilt on a language switch) re-runs + // its notification effect without re-delivering a persisted error. + const handleShellError = useCallback( + (error: Error) => { + clearInsight(); + setEditingMessage(undefined); + setSwitchingSessionId(undefined); + setCreatingSession(false); + setHostNotice({ + tone: 'error', + text: error.message || t('session.loadError'), + }); + }, + [clearInsight, t], + ); + + // A retriable connection failure can leave a session switch pending + // forever — neither settling into the exact session id nor erroring — and + // the blocking overlay would lock the panel until a reload. Bound it the + // way the pre-cutover host did. + useEffect(() => { + if (!switchingSessionId && !creatingSession) return; + const timer = setTimeout(() => { + setSwitchingSessionId(undefined); + setCreatingSession(false); + setHostNotice({ tone: 'error', text: t('session.switchTimeout') }); + }, SESSION_SWITCH_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [switchingSessionId, creatingSession, t]); + + const loadSessionHistory = useCallback( + async (cursor?: string) => { + if (!daemonClient || !runtime?.workspaceCwd || sessionListLoading) return; + setSessionListLoading(true); + setSessionListError(undefined); + try { + const page = await daemonClient + .workspaceByCwd(runtime.workspaceCwd) + .listWorkspaceSessionsPage({ + pageSize: 20, + cursor, + archiveState: 'active', + // Only conversations started from VS Code. The daemon is shared + // with the CLI and the browser Web Shell for this workspace, so an + // unfiltered page lists sessions the user never opened here. + sourceType: VSCODE_SESSION_SOURCE_TYPE, + }); + const pageSessions = Array.isArray(page.sessions) ? page.sessions : []; + setSessions((current) => { + const merged = new Map( + current.map((session) => [session.sessionId, session]), + ); + for (const session of pageSessions) { + merged.set(session.sessionId, session); + } + if ( + runtime.sessionId && + !merged.has(runtime.sessionId) && + runtime.workspaceCwd + ) { + merged.set(runtime.sessionId, { + sessionId: runtime.sessionId, + workspaceCwd: runtime.workspaceCwd, + displayName: sessionTitle || undefined, + }); + } + return Array.from(merged.values()); + }); + setSessionCursor(page.nextCursor); + } catch (error) { + setSessionListError( + error instanceof Error ? error.message : t('session.loadFailed'), + ); + } finally { + setSessionListLoading(false); + } + }, + [ + daemonClient, + runtime?.sessionId, + runtime?.workspaceCwd, + sessionListLoading, + sessionTitle, + t, + ], + ); + + const openSessionHistory = useCallback(() => { + setSessionHistoryOpen(true); + setSessionSearchQuery(''); + void loadSessionHistory(); + }, [loadSessionHistory]); + + const closeSessionHistory = useCallback(() => { + setSessionHistoryOpen(false); + historyButtonRef.current?.focus(); + }, []); + + const openReviewDiff = useCallback( + (request: TurnOutputOpenRequest) => { + if (request.kind !== 'review' || request.changes.length === 0) return; + vscode.postMessage({ + type: 'openDiffList', + data: { + selectedPath: request.selectedPath, + changes: request.changes.map((change) => ({ + path: change.path, + additions: change.additions, + deletions: change.deletions, + diffs: change.diffs, + })), + }, + }); + }, + [vscode], + ); + + const closeOpenPermissionDiffs = useCallback(() => { + for (const path of openPermissionDiffsRef.current.values()) { + vscode.postMessage({ type: 'closeDiff', data: { path } }); + } + openPermissionDiffsRef.current.clear(); + }, [vscode]); + + const updateTranscript = useCallback( + (blocks: readonly DaemonTranscriptBlock[]) => { + transcriptBlocksRef.current = blocks; + const pendingIds = new Set(); + let permissionToFocus: string | undefined; + for (const block of blocks) { + if (block.kind !== 'permission' || block.resolved) { + continue; + } + permissionToFocus = block.requestId; + const diff = permissionDiffPreview(block); + if (!diff) continue; + const { path, oldText, newText } = diff; + pendingIds.add(block.requestId); + if (openPermissionDiffsRef.current.has(block.requestId)) continue; + openPermissionDiffsRef.current.set(block.requestId, path); + vscode.postMessage({ + type: 'openDiff', + data: { path, oldText, newText, source: 'web-shell' }, + }); + } + for (const [requestId, path] of openPermissionDiffsRef.current) { + if (pendingIds.has(requestId)) continue; + openPermissionDiffsRef.current.delete(requestId); + vscode.postMessage({ type: 'closeDiff', data: { path } }); + } + if ( + permissionToFocus && + focusedPermissionRequestIdRef.current !== permissionToFocus + ) { + focusedPermissionRequestIdRef.current = permissionToFocus; + window.requestAnimationFrame(() => { + const option = document.querySelector( + '[data-web-shell-permission-panel] [data-web-shell-permission-option][tabindex="0"]', + ); + option?.focus(); + }); + } + }, + [vscode], + ); + + useEffect( + () => () => { + closeOpenPermissionDiffs(); + }, + [closeOpenPermissionDiffs], + ); + + useEffect(() => { + const openWorkspaceFile = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + const anchor = target?.closest('a'); + if (!(anchor instanceof HTMLAnchorElement)) return; + const filePath = resolveFileLinkFromAnchor(anchor); + if (!filePath) return; + event.preventDefault(); + event.stopPropagation(); + vscode.postMessage({ type: 'openFile', data: { path: filePath } }); + }; + document.addEventListener('click', openWorkspaceFile, true); + return () => document.removeEventListener('click', openWorkspaceFile, true); + }, [vscode]); + + useEffect(() => { + const trackContextMenu = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + contextMenuRowKeyRef.current = + target + ?.closest('[data-message-row-key]') + ?.getAttribute('data-message-row-key') ?? null; + vscode.postMessage({ type: 'contextMenuTriggered', data: {} }); + }; + document.addEventListener('contextmenu', trackContextMenu, true); + return () => + document.removeEventListener('contextmenu', trackContextMenu, true); + }, [vscode]); + + useEffect(() => { + const handleCopyCommand = (event: MessageEvent) => { + const message = event.data as { + type?: string; + data?: { action?: string }; + }; + if (message.type !== 'copyCommand') return; + + const blocks = transcriptBlocksRef.current; + let text: string | null = null; + if (message.data?.action === 'copyMessage') { + const block = findBlockByRowKey(blocks, contextMenuRowKeyRef.current); + text = block ? getBlockCopyText(block) : null; + } else if (message.data?.action === 'copyAllMessages') { + text = formatBlocksForCopyAll(blocks); + } else if (message.data?.action === 'copyLastReply') { + text = findLastAssistantText(blocks); + } + if (text) { + vscode.postMessage({ type: 'copyToClipboard', data: { text } }); + } + }; + window.addEventListener('message', handleCopyCommand); + return () => window.removeEventListener('message', handleCopyCommand); + }, [vscode]); + + useEffect(() => { + const observer = new MutationObserver(() => setTheme(readTheme())); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['data-vscode-theme-kind', 'class'], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!accountInfo) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setAccountInfo(undefined); + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [accountInfo]); + + useEffect(() => { + const receiveBootstrap = (event: MessageEvent) => { + const message = event.data as { + type?: string; + data?: Record | ReturnType; + }; + if ( + message.type === 'webShellBootstrap' && + typeof message.data?.baseUrl === 'string' + ) { + setRuntime( + message.data as NonNullable>, + ); + } else if (message.type === 'webShellBootstrapError') { + const errorMessage = (message.data as { message?: unknown } | null) + ?.message; + const text = + typeof errorMessage === 'string' ? errorMessage : t('boot.failed'); + setRuntimeError(text); + // Before bootstrap this renders as the full-panel startup state. After + // it, `runtime` is set and that branch is gone, so the same failure + // would be invisible — show it over the transcript instead. + if (runtimeRef.current) setHostNotice({ tone: 'error', text }); + } else if (message.type === 'error') { + const text = (message.data as { message?: unknown } | null)?.message; + if (typeof text === 'string') setHostNotice({ tone: 'error', text }); + } else if (message.type === 'accountInfo' && message.data) { + setAccountInfo(message.data as AccountInfo); + } else if (message.type === 'authState') { + const state = (message.data as { authenticated?: unknown } | null) + ?.authenticated; + setAuthenticated(typeof state === 'boolean' ? state : null); + if (state === false) { + setAuthConnecting(false); + clearInsight(); + } else if (state === true) { + setAuthConnecting(false); + setAuthError(undefined); + } + } else if ( + message.type === 'authSuccess' || + message.type === 'agentConnected' + ) { + setAuthenticated(true); + setAuthConnecting(false); + setAuthError(undefined); + if (message.type === 'authSuccess') { + setHostNotice({ tone: 'info', text: t('auth.signedIn') }); + } + } else if (message.type === 'authCancelled') { + // A cancelled auth flow must not hide an already-authenticated + // session behind the onboarding screen; only an unknown auth state + // settles to unauthenticated here. + setAuthenticated((current) => current ?? false); + setAuthConnecting(false); + setAuthError(undefined); + clearInsight(); + } else if ( + message.type === 'authError' || + message.type === 'agentConnectionError' + ) { + const data = message.data as { + message?: unknown; + error?: unknown; + } | null; + const text = + typeof data?.message === 'string' + ? data.message + : typeof data?.error === 'string' + ? data.error + : t('auth.failed'); + setAuthenticated(false); + setAuthConnecting(false); + setAuthError(text); + clearInsight(); + } else if (message.type === 'insightProgress' && message.data) { + const data = message.data as { + stage?: unknown; + progress?: unknown; + detail?: unknown; + }; + if ( + typeof data.stage === 'string' && + typeof data.progress === 'number' + ) { + setInsightReportPath(undefined); + setInsightProgress({ + stage: data.stage, + progress: data.progress, + detail: typeof data.detail === 'string' ? data.detail : undefined, + }); + } + } else if (message.type === 'insightProgressCleared') { + clearInsight(); + } else if (message.type === 'insightReportReady') { + const path = (message.data as { path?: unknown } | null)?.path; + setInsightProgress(undefined); + setInsightReportPath(typeof path === 'string' ? path : undefined); + } else if (message.type === 'message' && message.data) { + const data = message.data as { + content?: unknown; + localOnly?: unknown; + }; + if (data.localOnly && typeof data.content === 'string') { + setHostNotice({ tone: 'info', text: data.content }); + } + } else if (message.type === 'exportCompleted' && message.data) { + const data = message.data as { + format?: unknown; + filename?: unknown; + filePath?: unknown; + }; + if ( + typeof data.format === 'string' && + typeof data.filename === 'string' && + typeof data.filePath === 'string' + ) { + setHostNotice({ + tone: 'info', + text: `Session exported to ${data.format}: ${data.filename}`, + action: { label: 'Open', path: data.filePath }, + }); + } + } else if (message.type === 'activeEditorChanged') { + const data = message.data as { + fileName?: unknown; + filePath?: unknown; + selection?: ActiveFileContext['selection']; + }; + if ( + typeof data.fileName === 'string' && + typeof data.filePath === 'string' + ) { + setActiveFile({ + fileName: data.fileName, + filePath: data.filePath, + selection: data.selection, + }); + // The host fires this on every selection change, including plain + // cursor moves; only an actual file change may re-arm inclusion, + // or a click silently undoes the user's explicit exclusion. + if (previousActiveFilePathRef.current !== data.filePath) { + setIncludeActiveFile(true); + } + previousActiveFilePathRef.current = data.filePath; + } else { + setActiveFile(undefined); + previousActiveFilePathRef.current = undefined; + } + } else if ( + message.type === 'modeChanged' || + message.type === 'modeInfo' + ) { + const modeData = message.data as { + modeId?: unknown; + currentModeId?: unknown; + } | null; + const modeId = modeData?.modeId ?? modeData?.currentModeId; + if (isAutomaticApprovalMode(modeId)) { + closeOpenPermissionDiffs(); + } else { + updateTranscript(transcriptBlocksRef.current); + } + } else if (message.type === 'fileAttached') { + const data = message.data as { + id?: unknown; + name?: unknown; + value?: unknown; + }; + if (typeof data.name === 'string' && typeof data.value === 'string') { + composerRef.current?.addTags([ + { + id: + typeof data.id === 'string' + ? data.id + : `vscode-file:${data.value}`, + kind: 'file', + label: data.name, + value: data.value, + metadata: { path: data.value }, + serialized: `@${data.value}`, + }, + ]); + composerRef.current?.focus?.(); + } + } + }; + window.addEventListener('message', receiveBootstrap); + vscode.postMessage({ type: 'webShellReady', data: {} }); + return () => window.removeEventListener('message', receiveBootstrap); + }, [clearInsight, closeOpenPermissionDiffs, t, updateTranscript, vscode]); + + if (!runtime) { + return ( +
+ {!runtimeError && ( + <> + +
+ ); + } + + return ( +
+ + {sessionHistoryOpen && ( + void loadSessionHistory(sessionCursor)} + onSelect={(session) => { + if (session.sessionId === runtime.sessionId) return; + closeOpenPermissionDiffs(); + clearInsight(); + closeSessionHistory(); + setEditingMessage(undefined); + composerRef.current?.clear({ text: true, tags: true }); + setSwitchingSessionId(session.sessionId); + sessionSwitchStartedAtRef.current = Date.now(); + setSessionTitle(session.displayName || t('session.past')); + requestAnimationFrame(() => { + setRuntime((current) => + current && current.sessionId !== session.sessionId + ? { ...current, sessionId: session.sessionId } + : current, + ); + }); + }} + onRename={async (session, title) => { + if (!daemonClient || !runtime.workspaceCwd) return; + setSessionListError(undefined); + try { + const result = await daemonClient + .workspaceByCwd(runtime.workspaceCwd) + .updateSessionMetadata(session.sessionId, { + displayName: title, + }); + const displayName = result.displayName || title; + setSessions((current) => + current.map((entry) => + entry.sessionId === session.sessionId + ? { ...entry, displayName } + : entry, + ), + ); + if (session.sessionId === runtime.sessionId) { + setSessionTitle(displayName); + if (runtime.hostKind === 'panel') { + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title: displayName }, + }); + } + } + } catch (error) { + setSessionListError( + error instanceof Error + ? error.message + : t('session.renameFailed'), + ); + } + }} + onDelete={async (session) => { + if ( + !daemonClient || + !runtime.workspaceCwd || + !session.sessionId || + session.sessionId === runtime.sessionId + ) { + return; + } + setSessionListError(undefined); + try { + await daemonClient + .workspaceByCwd(runtime.workspaceCwd) + .deleteSessionsData([session.sessionId]); + setSessions((current) => + current.filter( + (entry) => entry.sessionId !== session.sessionId, + ), + ); + } catch (error) { + setSessionListError( + error instanceof Error + ? error.message + : t('session.deleteFailed'), + ); + } + }} + /> + )} + {(switchingSessionId || creatingSession) && ( +
+
+ )} +
+ + + +
+ {hostNotice && ( +
+ + {hostNotice.text} + + {hostNotice.action && ( + + )} + +
+ )} + {(insightProgress || insightReportPath) && ( +
+ {insightProgress ? ( + <> +
+
+ {insightProgress.stage} +
+
+ {insightProgress.detail ?? t('insight.progressDetail')} +
+
+ + {Math.max( + 0, + Math.min(100, Math.round(insightProgress.progress)), + )} + % + + + ) : ( + <> + + {t('insight.ready')} {insightReportPath} + + + + )} +
+ )} + {authenticated === false ? ( + { + setAuthConnecting(true); + setAuthError(undefined); + vscode.postMessage({ type: 'auth', data: {} }); + }} + /> + ) : ( + { + if (switchingSessionId && sessionId !== switchingSessionId) return; + clearInsight(); + setEditingMessage(undefined); + vscode.postMessage({ + type: 'webShellSessionChanged', + data: { sessionId, workspaceCwd: runtime.workspaceCwd }, + }); + setRuntime((current) => + current && current.sessionId !== sessionId + ? { ...current, sessionId } + : current, + ); + if (sessionId === switchingSessionId) { + const remaining = Math.max( + 0, + SESSION_SWITCH_MIN_VISIBLE_MS - + (Date.now() - sessionSwitchStartedAtRef.current), + ); + if (sessionSwitchTimerRef.current) { + clearTimeout(sessionSwitchTimerRef.current); + } + sessionSwitchTimerRef.current = setTimeout(() => { + setSwitchingSessionId(undefined); + sessionSwitchTimerRef.current = undefined; + }, remaining); + } + }} + onSessionInfoChange={({ sessionId, sessionName }) => { + if (!switchingSessionId || sessionId === switchingSessionId) { + const title = sessionName || t('session.new'); + setSessionTitle(title); + if (runtime.hostKind === 'panel') { + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title }, + }); + } + } + }} + onError={handleShellError} + sidebar={false} + compactThinking + collapseCompletedTurns + composerToolbarActions={COMPOSER_TOOLBAR_ACTIONS} + mainModelFilter={isVsCodeModelVisible} + compactComposerOverlays + autoSubmitSlashCommands + askUserFreeTextLabel={t('askUser.other')} + additionalSlashCommands={slashCommands} + hiddenSlashCommands={[...VSCODE_HIDDEN_SLASH_COMMANDS]} + onSlashCommand={({ command, input }) => { + if (command === 'auth' || command === 'login') { + vscode.postMessage({ type: 'auth', data: {} }); + return true; + } + if (command === 'account') { + vscode.postMessage({ type: 'getAccountInfo', data: {} }); + return true; + } + if (command === 'export') { + vscode.postMessage({ + type: 'exportSession', + data: { text: input, sessionId: runtime.sessionId }, + }); + return true; + } + return false; + }} + contextUsageAlwaysVisible + userMessageEditing + cycleModeOnTab + onUserMessageEditRequest={(turnIndex, content) => { + const queuedPrompt = latestSubmittedPromptRef.current; + const queuedPromptForEdit = + queuedPrompt && + queuedPrompt.sessionId === runtime.sessionId && + queuedPrompt.prompt !== content + ? queuedPrompt + : undefined; + const editsQueuedPrompt = queuedPromptForEdit !== undefined; + const editContent = queuedPromptForEdit + ? queuedPromptForEdit.prompt + : content; + composerRef.current?.clear({ text: true, tags: true }); + composerRef.current?.setText(editContent); + composerRef.current?.focus?.(); + setEditingMessage({ + turnIndex: editsQueuedPrompt ? undefined : turnIndex, + }); + return true; + }} + onSessionChange={(event) => { + if (event.type === 'submit') { + latestSubmittedPromptRef.current = event.queued + ? { sessionId: event.sessionId, prompt: event.prompt } + : undefined; + } else if ( + latestSubmittedPromptRef.current?.sessionId === event.sessionId + ) { + latestSubmittedPromptRef.current = undefined; + } + }} + messageTurnOutputs={['file']} + onFileReviewOpen={openReviewDiff} + onInsightReportOpen={(path) => + vscode.postMessage({ + type: 'openInsightReport', + data: { path }, + }) + } + onTranscriptChange={updateTranscript} + composerPlaceholders={{ + idle: t('composer.placeholder'), + }} + composerRef={composerRef} + prepareSubmit={async (submission) => { + if (editingMessage) { + const sessionId = submission.sessionId ?? runtime.sessionId; + if (!daemonClient || !sessionId) { + throw new Error(t('composer.editUnavailable')); + } + const { snapshots } = + await daemonClient.getRewindSnapshots(sessionId); + const snapshot = + editingMessage.turnIndex === undefined + ? snapshots.reduce<(typeof snapshots)[number] | undefined>( + (latest, entry) => + !latest || entry.turnIndex > latest.turnIndex + ? entry + : latest, + undefined, + ) + : snapshots.find( + (entry) => entry.turnIndex === editingMessage.turnIndex, + ); + if (!snapshot) { + throw new Error(t('composer.editExpired')); + } + await daemonClient.rewindSession(sessionId, snapshot.promptId, { + clientId: runtime.clientId, + rewindFiles: false, + }); + setEditingMessage(undefined); + clearInsight(); + } + + if (!activeFile || !includeActiveFile) return undefined; + const normalizedWorkspace = runtime.workspaceCwd?.replace( + /\\/g, + '/', + ); + const normalizedFile = activeFile.filePath.replace(/\\/g, '/'); + const relativePath = + normalizedWorkspace && + normalizedFile.startsWith(`${normalizedWorkspace}/`) + ? normalizedFile.slice(normalizedWorkspace.length + 1) + : activeFile.fileName; + const reference = `@${relativePath}`; + const selectedLines = activeFile.selection + ? ` (selected lines ${activeFile.selection.startLine}-${activeFile.selection.endLine})` + : ''; + // Mention annotations carry the workspace-relative path the + // file picker produced, so compare in both path spaces. + const alreadyIncluded = submission.inputAnnotations.some( + (annotation) => + annotation.reference.value === activeFile.filePath || + annotation.reference.value === relativePath, + ); + // Bounded match: `@editor.ts` must not suppress a typed + // `@editor.tsx` mention of a sibling file. + const mentionsReference = + submission.prompt === reference || + submission.prompt.startsWith(`${reference} `); + const prefix = + alreadyIncluded || mentionsReference + ? '' + : `${reference}${selectedLines} `; + const prompt = `${prefix}${submission.prompt}`; + const inputAnnotations = submission.inputAnnotations.map( + (annotation) => + prefix + ? { + ...annotation, + start: annotation.start + prefix.length, + end: annotation.end + prefix.length, + } + : annotation, + ); + if (!alreadyIncluded) { + inputAnnotations.unshift({ + type: 'reference', + start: 0, + end: reference.length, + text: reference, + reference: { + id: `vscode-active-file:${activeFile.filePath}`, + kind: 'file', + label: activeFile.fileName, + value: activeFile.filePath, + metadata: { + path: activeFile.filePath, + selection: activeFile.selection, + }, + serialized: reference, + }, + }); + } + return { + prompt, + inputAnnotations, + }; + }} + renderComposerHeader={() => + editingMessage ? ( +
+ + {t('composer.editing')} + + +
+ ) : null + } + renderComposerToolbarStart={({ disabled, currentModel }) => { + currentModelIdRef.current = currentModel || undefined; + return ( + + + {activeFile && ( + + )} + + ); + }} + /> + )} + {accountInfo && ( +
setAccountInfo(undefined)} + style={{ + position: 'absolute', + inset: 0, + zIndex: 1100, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + background: 'rgba(0, 0, 0, 0.45)', + }} + > +
event.stopPropagation()} + style={{ + display: 'flex', + width: 'min(480px, 100%)', + maxHeight: 'min(480px, calc(100% - 24px))', + flexDirection: 'column', + overflow: 'hidden', + border: '1px solid var(--vscode-widget-border)', + borderRadius: 6, + background: 'var(--vscode-editorWidget-background)', + color: 'var(--vscode-editorWidget-foreground)', + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)', + }} + > +
+ + {t('account.title')} + + +
+
+ {accountInfo.error ? ( + <> + + {t('account.error')} + + + {accountInfo.error} + + + ) : ( + <> + + {t('account.authType')} + + + {accountInfo.authType || t('account.unknown')} + + {accountInfo.envKey && ( + <> + + {t('account.envKey')} + + + {accountInfo.envKey} + + + )} + {accountInfo.baseUrl && ( + <> + + {t('account.baseUrl')} + + + {accountInfo.baseUrl} + + + )} + {accountInfo.modelId && ( + <> + + {t('account.model')} + + + {accountInfo.modelId} + + + )} + + )} +
+
+
+ )} +
+ ); +} diff --git a/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.test.ts b/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.test.ts deleted file mode 100644 index b7f4ad6eea6..00000000000 --- a/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { - createDaemonTranscriptState, - selectTranscriptBlocks, -} from '@qwen-code/sdk/daemon'; -import { describe, expect, it } from 'vitest'; -import { - cachedMessageToNotification, - reduceSessionNotification, -} from './acpTranscriptAdapter.js'; - -function userTextNotification(text: string): SessionNotification { - return { - sessionId: 'session-1', - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text }, - }, - }; -} - -describe('reduceSessionNotification', () => { - it('wraps an ACP notification into a daemon event and reduces it', () => { - const state = reduceSessionNotification( - createDaemonTranscriptState(), - userTextNotification('hello world'), - ); - - const blocks = selectTranscriptBlocks(state); - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ kind: 'user', text: 'hello world' }); - }); - - it('merges consecutive user text chunks into one block', () => { - let state = createDaemonTranscriptState(); - state = reduceSessionNotification(state, userTextNotification('hello ')); - state = reduceSessionNotification(state, userTextNotification('world')); - - const blocks = selectTranscriptBlocks(state); - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ kind: 'user', text: 'hello world' }); - }); -}); - -function seedCachedRows( - rows: ReadonlyArray<{ role?: string; content?: string }>, -) { - let state = createDaemonTranscriptState(); - for (const row of rows) { - const notification = cachedMessageToNotification(row, 'session-1'); - if (notification) { - state = reduceSessionNotification(state, notification); - } - } - return selectTranscriptBlocks(state); -} - -describe('cachedMessageToNotification', () => { - it('converts a cached row with renderable text into a notification', () => { - expect( - cachedMessageToNotification( - { role: 'user', content: 'hello' }, - 'session-1', - ), - ).toEqual({ - sessionId: 'session-1', - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'hello' }, - _meta: { qwenDiscreteMessage: true }, - }, - }); - }); - - it('strips persisted image references from restored user text', () => { - expect( - cachedMessageToNotification( - { - role: 'user', - content: 'look at this\n\n@/tmp/clipboard/clipboard-1.png', - }, - 'session-1', - ), - ).toMatchObject({ - update: { - content: { type: 'text', text: 'look at this' }, - }, - }); - - expect( - cachedMessageToNotification( - { role: 'user', content: '@/tmp/clipboard/clipboard-1.png' }, - 'session-1', - ), - ).toBeNull(); - }); - - it('stamps every cached role as a discrete message', () => { - for (const role of ['user', 'assistant', 'thinking']) { - const notification = cachedMessageToNotification( - { role, content: 'text' }, - 's', - ); - expect(notification).not.toBeNull(); - expect((notification as SessionNotification).update).toMatchObject({ - _meta: { qwenDiscreteMessage: true }, - }); - } - }); - - it('keeps consecutive same-role cached rows as discrete blocks', () => { - const blocks = seedCachedRows([ - { role: 'assistant', content: "I'll check the file." }, - { role: 'assistant', content: 'Tool Result (call_1): success' }, - { role: 'assistant', content: 'Tool Call: read_file - completed (12ms)' }, - ]); - - expect(blocks).toHaveLength(3); - expect(blocks[0]).toMatchObject({ - kind: 'assistant', - text: "I'll check the file.", - }); - expect(blocks[1]).toMatchObject({ - kind: 'assistant', - text: 'Tool Result (call_1): success', - }); - expect(blocks[2]).toMatchObject({ - kind: 'assistant', - text: 'Tool Call: read_file - completed (12ms)', - }); - }); - - it('does not fuse turns across a dropped whitespace-only row', () => { - const blocks = seedCachedRows([ - { role: 'assistant', content: 'turn 1 answer' }, - { role: 'user', content: ' ' }, - { role: 'assistant', content: 'turn 2 answer' }, - ]); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - kind: 'assistant', - text: 'turn 1 answer', - }); - expect(blocks[1]).toMatchObject({ - kind: 'assistant', - text: 'turn 2 answer', - }); - }); - - it('returns null for whitespace-only content instead of an empty block', () => { - expect( - cachedMessageToNotification({ role: 'user', content: ' ' }, 's'), - ).toBeNull(); - expect( - cachedMessageToNotification({ role: 'assistant', content: '\n\t ' }, 's'), - ).toBeNull(); - }); - - it('returns null for empty, missing, or non-string content', () => { - expect( - cachedMessageToNotification({ role: 'user', content: '' }, 's'), - ).toBeNull(); - expect(cachedMessageToNotification({ role: 'user' }, 's')).toBeNull(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.ts b/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.ts deleted file mode 100644 index 46c380986ee..00000000000 --- a/packages/vscode-ide-companion/src/webview/adapters/acpTranscriptAdapter.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Thin adapter that bridges ACP session/update notifications into the shared - * SDK daemon transcript reducer. The ACP `SessionNotification` payload is - * structurally identical to the daemon `session_update` envelope, so no - * per-field projection is needed: wrap the notification once, then let - * `normalizeDaemonEvent` + `reduceDaemonTranscriptEvents` do the work. - */ -import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { - normalizeDaemonEvent, - reduceDaemonTranscriptEvents, -} from '@qwen-code/sdk/daemon'; -import type { DaemonEvent, DaemonTranscriptState } from '@qwen-code/sdk/daemon'; -import { splitMessageContentForImages } from '../../utils/imageSupport.js'; - -/** Reduce one ACP notification into the transcript state. */ -export function reduceSessionNotification( - state: DaemonTranscriptState, - notification: SessionNotification, -): DaemonTranscriptState { - const event: DaemonEvent = { - v: 1, - type: 'session_update', - data: notification, - }; - return reduceDaemonTranscriptEvents(state, normalizeDaemonEvent(event)); -} - -/** Minimal shape of cached history rows (ChatMessage) delivered offline. */ -export interface CachedTranscriptMessage { - role?: string; - content?: string; -} - -/** - * Anti-merge marker the shared transcript reducer honors: `canMergeTextDelta` - * refuses to fold a chunk carrying it into the active block. Cached history - * rows are discrete messages, but `readJsonlMessages` reconstructs runs of - * consecutive same-role rows per turn (Tool Result / telemetry / Plan rows); - * seeded as bare chunks they would merge into one plain-concatenated block, - * unlike live replays where each row arrives stamped. - */ -const CACHED_ROW_META = { qwenDiscreteMessage: true } as const; - -/** - * Convert one cached ChatMessage-shaped history row into the ACP - * session/update notification the shared reducer already understands. - * Returns `null` for rows without renderable text so offline restores and - * load-failure fallbacks render the same timeline as live replays. - */ -export function cachedMessageToNotification( - message: CachedTranscriptMessage, - sessionId: string, -): SessionNotification | null { - if ( - typeof message?.content !== 'string' || - message.content.trim().length === 0 - ) { - return null; - } - const text = - message.role === 'user' - ? splitMessageContentForImages(message.content).text - : message.content; - if (text.trim().length === 0) { - return null; - } - const content = { type: 'text' as const, text }; - switch (message.role) { - case 'user': - return { - sessionId, - update: { - sessionUpdate: 'user_message_chunk', - content, - _meta: CACHED_ROW_META, - }, - }; - case 'assistant': - return { - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content, - _meta: CACHED_ROW_META, - }, - }; - case 'thinking': - return { - sessionId, - update: { - sessionUpdate: 'agent_thought_chunk', - content, - _meta: CACHED_ROW_META, - }, - }; - default: - return null; - } -} diff --git a/packages/vscode-ide-companion/src/webview/components/AccountInfoDialog.tsx b/packages/vscode-ide-companion/src/webview/components/AccountInfoDialog.tsx deleted file mode 100644 index e08956a0df9..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/AccountInfoDialog.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { FC } from 'react'; -import { useEffect } from 'react'; - -export interface AccountInfo { - authType?: string | null; - baseUrl?: string | null; - envKey?: string | null; - modelId?: string | null; - error?: string; -} - -interface AccountInfoDialogProps { - info: AccountInfo; - onClose: () => void; -} - -const AUTH_LABELS: Record = { - 'qwen-oauth': 'Qwen OAuth', - openai: 'OpenAI-compatible', - gemini: 'Gemini', - anthropic: 'Anthropic', - 'vertex-ai': 'Vertex AI', -}; - -export const AccountInfoDialog: FC = ({ - info, - onClose, -}) => { - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - onClose(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); - - const rows: Array<{ label: string; value: string; accent?: boolean }> = []; - - if (info.error) { - rows.push({ label: 'Error', value: info.error }); - } else { - const authLabel = - AUTH_LABELS[info.authType ?? ''] ?? info.authType ?? 'Unknown'; - rows.push({ label: 'Auth Method', value: authLabel }); - - if (info.envKey) { - rows.push({ label: 'API Key Env', value: info.envKey }); - } - - if (info.baseUrl) { - rows.push({ label: 'Base URL', value: info.baseUrl }); - } - - if (info.modelId) { - rows.push({ label: 'Current Model', value: info.modelId }); - } - } - - return ( - /* Backdrop */ -
- {/* Card */} -
e.stopPropagation()} - > - {/* Header */} -
- - Account Information - - -
- - {/* Rows */} -
- {rows.map(({ label, value, accent }) => ( -
- - {label} - - - {value} - -
- ))} -
-
-
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx b/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx new file mode 100644 index 00000000000..8bd724ea6ec --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CSSProperties } from 'react'; +// eslint-disable-next-line import/no-internal-modules -- bundle the extension icon into the webview +import iconUrl from '../../../assets/icon.png'; +import type { ChromeStrings } from '../strings.js'; + +interface QwenOnboardingProps { + connecting: boolean; + error?: string; + t: ChromeStrings; + onGetStarted: () => void; +} + +const BUTTON_STYLE: CSSProperties = { + display: 'inline-flex', + width: '100%', + minHeight: 32, + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: '6px 12px', + border: '1px solid transparent', + borderRadius: 4, + font: 'inherit', + fontWeight: 600, +}; + +export function QwenOnboarding({ + connecting, + error, + t, + onGetStarted, +}: QwenOnboardingProps) { + return ( +
+
+ Qwen Code +
+
+ {t('onboarding.title')} +
+
+ {t('onboarding.subtitle')} +
+
+
+ + {error && ( +
+ {error} +
+ )} +
+
+ {t('onboarding.providers')} +
+
+
+ ); +} diff --git a/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.test.tsx b/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.test.tsx new file mode 100644 index 00000000000..5ff5082df66 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.test.tsx @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import { SessionHistoryDropdown } from './SessionHistoryDropdown.js'; +import { createChromeStrings } from '../strings.js'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const t = createChromeStrings('en'); + +function makeSession( + sessionId: string, + displayName: string, +): DaemonSessionSummary { + const stamp = new Date().toISOString(); + return { + sessionId, + workspaceCwd: '/workspace', + displayName, + createdAt: stamp, + updatedAt: stamp, + }; +} + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; + +async function renderDropdown() { + const onClose = vi.fn(); + const onSelect = vi.fn(); + const onRename = vi.fn(async () => {}); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + {}} + onSelect={onSelect} + onRename={onRename} + onDelete={async () => {}} + onLoadMore={() => {}} + onClose={onClose} + />, + ); + await Promise.resolve(); + }); + mounted.push({ container, root }); + return { container, onClose }; +} + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +describe('SessionHistoryDropdown focus management', () => { + it('restores focus inside the dialog when a rename ends, keeping Escape working', async () => { + const { container, onClose } = await renderDropdown(); + + const row = container.querySelector( + '[data-session-id="s2"]', + ) as HTMLElement; + const renameButton = row.querySelector('button') as HTMLButtonElement; + await act(async () => { + renameButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const input = row.querySelector('input') as HTMLInputElement; + expect(document.activeElement).toBe(input); + + // Commit the rename with Enter (the input blurs and unmounts). + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + await Promise.resolve(); + }); + + // Focus must be back inside the dialog — otherwise Escape and the Tab + // trap die with the unmounted input. + const dialog = document.getElementById('qwen-session-history'); + expect(dialog).not.toBeNull(); + expect(dialog?.contains(document.activeElement)).toBe(true); + + await act(async () => { + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + await Promise.resolve(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('still closes on Escape when focus has fallen to ', async () => { + const { onClose } = await renderDropdown(); + + (document.activeElement as HTMLElement | null)?.blur(); + expect(document.activeElement).toBe(document.body); + + await act(async () => { + document.body.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + await Promise.resolve(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('pulls focus back into the dialog when Tab is pressed with focus outside', async () => { + await renderDropdown(); + + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + expect(document.activeElement).toBe(outside); + + await act(async () => { + outside.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + await Promise.resolve(); + }); + + const dialog = document.getElementById('qwen-session-history'); + expect(dialog?.contains(document.activeElement)).toBe(true); + outside.remove(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.tsx b/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.tsx new file mode 100644 index 00000000000..2ed90085bcf --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/SessionHistoryDropdown.tsx @@ -0,0 +1,630 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useState } from 'react'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import type { ChromeStrings } from '../strings.js'; +import { LoaderCircle, Pencil, Search, Trash2 } from 'lucide-react'; + +interface SessionHistoryDropdownProps { + t: ChromeStrings; + sessions: readonly DaemonSessionSummary[]; + currentSessionId?: string; + searchQuery: string; + loading: boolean; + hasMore: boolean; + error?: string; + onSearchChange: (query: string) => void; + onSelect: (session: DaemonSessionSummary) => void; + onRename: (session: DaemonSessionSummary, title: string) => Promise; + onDelete: (session: DaemonSessionSummary) => Promise; + onLoadMore: () => void; + onClose: () => void; +} + +function groupSessions( + sessions: readonly DaemonSessionSummary[], + t: ChromeStrings, +) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const groups = new Map([ + [t('group.today'), []], + [t('group.yesterday'), []], + [t('group.thisWeek'), []], + [t('group.older'), []], + ]); + + for (const session of sessions) { + const timestamp = session.updatedAt ?? session.createdAt; + const date = timestamp ? new Date(timestamp) : undefined; + let label = t('group.older'); + if (date && !Number.isNaN(date.getTime())) { + const day = new Date(date); + day.setHours(0, 0, 0, 0); + if (day.getTime() === today.getTime()) label = t('group.today'); + else if (day.getTime() === yesterday.getTime()) { + label = t('group.yesterday'); + } else if (day.getTime() > today.getTime() - 7 * 86_400_000) { + label = t('group.thisWeek'); + } + } + groups.get(label)?.push(session); + } + + return Array.from(groups, ([label, entries]) => ({ + label, + sessions: entries, + })).filter((group) => group.sessions.length > 0); +} + +function timeAgo(timestamp: string | undefined, t: ChromeStrings): string { + if (!timestamp) return ''; + const elapsed = Date.now() - new Date(timestamp).getTime(); + if (!Number.isFinite(elapsed)) return ''; + const minutes = Math.floor(elapsed / 60_000); + if (minutes < 1) return t('time.now'); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(elapsed / 3_600_000); + if (hours < 24) return `${hours}h`; + const days = Math.floor(elapsed / 86_400_000); + if (days === 1) return t('group.yesterday'); + if (days < 7) return `${days}d`; + return new Date(timestamp).toLocaleDateString(); +} + +/** + * Row actions are revealed by hover *or* keyboard focus. Gating them on a + * React `hovered` flag alone left rename and delete unreachable without a + * mouse, and unmounting the focused button on mouse-out drops focus to + * ``; CSS keeps them mounted and reachable. + */ +const DROPDOWN_CSS = ` + .qwen-session-row-actions { visibility: hidden; } + .qwen-session-row:hover .qwen-session-row-actions, + .qwen-session-row:focus-within .qwen-session-row-actions, + .qwen-session-row-actions[data-confirming] { visibility: visible; } + .qwen-session-row:focus-visible, + .qwen-session-search:focus-visible, + .qwen-session-icon-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + .qwen-session-icon-button:hover { + background: var(--vscode-toolbar-hoverBackground); + } +`; + +export function SessionHistoryDropdown({ + t, + sessions, + currentSessionId, + searchQuery, + loading, + hasMore, + error, + onSearchChange, + onSelect, + onRename, + onDelete, + onLoadMore, + onClose, +}: SessionHistoryDropdownProps) { + const searchRef = useRef(null); + const renameRef = useRef(null); + const cancelRenameRef = useRef(false); + const [hoveredId, setHoveredId] = useState(); + const [renamingId, setRenamingId] = useState(); + const [renameValue, setRenameValue] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState(); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + // A stale "Delete?" must not survive a change of what is on screen. + useEffect(() => { + setConfirmDeleteId(undefined); + }, [searchQuery]); + + const lastRenamingIdRef = useRef(undefined); + useEffect(() => { + if (renamingId) { + lastRenamingIdRef.current = renamingId; + renameRef.current?.focus(); + renameRef.current?.select(); + return; + } + const finished = lastRenamingIdRef.current; + if (!finished) return; + lastRenamingIdRef.current = undefined; + // The rename input unmounts when the rename ends; without a restore, + // focus falls to and the dialog stops receiving key events. + const row = Array.from( + document.querySelectorAll( + '#qwen-session-history [data-session-id]', + ), + ).find((element) => element.dataset.sessionId === finished); + (row ?? searchRef.current)?.focus(); + }, [renamingId]); + + // Focus can also land on when a focused element unmounts (a deleted + // row) or non-focusable content is clicked. Those events never pass + // through the dialog div's onKeyDown, so guard Escape and re-trap Tab at + // the window level while the dropdown is mounted. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + return; + } + if ( + event.key !== 'Tab' || + document + .getElementById('qwen-session-history') + ?.contains(document.activeElement) + ) { + return; + } + event.preventDefault(); + searchRef.current?.focus(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + const filtered = searchQuery.trim() + ? sessions.filter((session) => + (session.displayName ?? 'Untitled') + .toLowerCase() + .includes(searchQuery.trim().toLowerCase()), + ) + : sessions; + + const finishRename = async (session: DaemonSessionSummary) => { + const cancelled = cancelRenameRef.current; + cancelRenameRef.current = false; + const title = renameValue.trim(); + setRenamingId(undefined); + if (!cancelled && title && title !== (session.displayName ?? '')) { + await onRename(session, title); + } + }; + + return ( + <> + + {!active && + (confirmDeleteId === session.sessionId ? ( + + ) : ( + + ))} + + )} + {!renaming ? ( + + {timeAgo(session.updatedAt ?? session.createdAt, t)} + + ) : null} +
+ ); + })} + + ))} + + {!loading && filtered.length === 0 && ( +
+ {searchQuery ? t('session.emptyFiltered') : t('session.empty')} +
+ )} + {loading && ( +
+
+ )} + + + + ); +} + +const iconButtonStyle = { + display: 'inline-flex', + width: 22, + height: 22, + alignItems: 'center', + justifyContent: 'center', + padding: 0, + border: 0, + borderRadius: 3, + background: 'transparent', + color: 'inherit', + cursor: 'pointer', +} as const; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx deleted file mode 100644 index f64acf4d3e7..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx +++ /dev/null @@ -1,527 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import type React from 'react'; -import { act, createRef } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import { ApprovalMode } from '../../../types/acpTypes.js'; -import type { CompletionItem } from '../../../types/completionItemTypes.js'; -import { InputForm } from './InputForm.js'; - -vi.mock('@qwen-code/webui', async () => { - const actual = await vi.importActual( - '../../../../../webui/src/components/layout/InputForm.tsx', - ); - - return { - InputForm: actual.InputForm, - getEditModeIcon: actual.getEditModeIcon, - PlanCompletedIcon: () => null, - }; -}); - -const completionItem: CompletionItem = { - id: 'create-issue', - label: '/create-issue', - type: 'command', - value: 'create-issue', -}; - -function renderInputForm(props?: { - onCompletionSelect?: (item: CompletionItem) => void; - onCompletionFill?: (item: CompletionItem) => void; - showModelSelector?: boolean; - availableModels?: ModelInfo[]; - currentModelId?: string | null; - onSelectModel?: (modelId: string) => void; - onCloseModelSelector?: () => void; - onModelSelectorClearance?: (heightPx: number) => void; -}) { - const container = document.createElement('div'); - document.body.appendChild(container); - - const root = createRoot(container); - const inputFieldRef = - createRef() as unknown as React.RefObject; - const onCompletionSelect = props?.onCompletionSelect ?? vi.fn(); - const onCompletionFill = props?.onCompletionFill ?? vi.fn(); - const onSelectModel = props?.onSelectModel ?? vi.fn(); - const onCloseModelSelector = props?.onCloseModelSelector ?? vi.fn(); - - act(() => { - root.render( - , - ); - }); - - return { - container, - root, - onCompletionSelect, - onCompletionFill, - onSelectModel, - onCloseModelSelector, - }; -} - -let root: Root | null = null; -let container: HTMLDivElement | null = null; - -beforeEach(() => { - vi.clearAllMocks(); - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: vi.fn(), - }); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } -}); - -describe('InputForm completion keyboard handling', () => { - it('uses onCompletionFill for Tab without triggering onCompletionSelect', () => { - const rendered = renderInputForm(); - root = rendered.root; - container = rendered.container; - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }), - ); - }); - - expect(rendered.onCompletionFill).toHaveBeenCalledWith(completionItem); - expect(rendered.onCompletionSelect).not.toHaveBeenCalled(); - }); - - it('keeps Enter mapped to onCompletionSelect', () => { - const rendered = renderInputForm(); - root = rendered.root; - container = rendered.container; - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - }), - ); - }); - - expect(rendered.onCompletionSelect).toHaveBeenCalledWith(completionItem); - expect(rendered.onCompletionFill).not.toHaveBeenCalled(); - }); -}); - -describe('InputForm model selector positioning (issue #8617)', () => { - const models: ModelInfo[] = [ - { modelId: 'model-a', name: 'Model A' }, - { modelId: 'model-b', name: 'Model B' }, - ]; - - function collectAncestors(el: HTMLElement): HTMLElement[] { - const ancestors: HTMLElement[] = []; - let current = el.parentElement; - while (current && current !== document.body) { - ancestors.push(current); - current = current.parentElement; - } - return ancestors; - } - - it('anchors the dropdown to the input form instead of the viewport', () => { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - const menu = container.querySelector( - '.model-selector', - ) as HTMLElement | null; - expect(menu).not.toBeNull(); - - const ancestors = collectAncestors(menu as HTMLElement); - - // The dropdown must not float above the message list via a - // viewport-fixed wrapper (the #8617 occlusion). - const fixedWrapper = ancestors.find((el) => - /(^|\s)fixed(\s|$)/.test(el.className), - ); - expect(fixedWrapper).toBeUndefined(); - - // The dropdown's positioning wrapper must grow upward from its anchor - // (same layout as webui CompletionMenu: absolute bottom-full). - const positionedWrapper = ancestors.find((el) => - /(^|\s)(absolute|fixed)(\s|$)/.test(el.className), - ); - expect(positionedWrapper).toBeDefined(); - expect(positionedWrapper?.className).toMatch(/(^|\s)bottom-full(\s|$)/); - - // The selector must live inside the input form's own stacking context: - // a shared relative wrapper that also contains the composer form. - const sharedWrapper = ancestors.find((el) => - /(^|\s)relative(\s|$)/.test(el.className), - ); - expect(sharedWrapper).toBeDefined(); - expect(sharedWrapper?.querySelector('form.composer-form')).not.toBeNull(); - }); - - it('sizes the positioning context to the form height so the dropdown clears the form', () => { - // jsdom performs no layout, so emulate the browser measurement the - // adapter relies on: capture the ResizeObserver, give the observed - // element a real height, and fire the observer callback. - const observers: Array<{ - callback: ResizeObserverCallback; - targets: Element[]; - }> = []; - const originalResizeObserver = globalThis.ResizeObserver; - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - value: class { - readonly targets: Element[] = []; - constructor(callback: ResizeObserverCallback) { - observers.push({ callback, targets: this.targets }); - } - observe(target: Element) { - this.targets.push(target); - } - unobserve() {} - disconnect() {} - }, - }); - - try { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - const menu = container.querySelector( - '.model-selector', - ) as HTMLElement | null; - expect(menu).not.toBeNull(); - const sharedWrapper = collectAncestors(menu as HTMLElement).find((el) => - /(^|\s)relative(\s|$)/.test(el.className), - ); - expect(sharedWrapper).toBeDefined(); - - // The adapter must measure the wrapper child that carries the base - // form, so the positioning context gets the form's real height - // instead of collapsing to zero (which anchors the dropdown at the - // viewport bottom, behind the opaque form). - const observed = observers.flatMap((entry) => entry.targets); - const formCarrier = observed.find((target) => - target.querySelector('form.composer-form'), - ); - expect(formCarrier).toBeDefined(); - expect(formCarrier?.parentElement).toBe(sharedWrapper); - - Object.defineProperty(formCarrier, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - height: 120, - width: 400, - top: 680, - bottom: 800, - left: 0, - right: 400, - x: 0, - y: 680, - toJSON: () => ({}), - }), - }); - - act(() => { - for (const entry of observers) { - entry.callback([], {} as ResizeObserver); - } - }); - - expect((sharedWrapper as HTMLElement).style.height).toBe('120px'); - - // The dropdown anchor must be the MEASURED form height (inline - // bottom), not the wrapper's rendered height (bottom-full): when the - // wrapper shrinks in a short webview, bottom-full would slide the - // anchor back behind the opaque form (issue #8617, both directions). - const positionedWrapper = collectAncestors(menu as HTMLElement).find( - (el) => /(^|\s)(absolute|fixed)(\s|$)/.test(el.className), - ); - expect((positionedWrapper as HTMLElement).style.bottom).toBe('120px'); - } finally { - // jsdom ships without ResizeObserver; restore the original value - // (undefined there), keeping the property writable for other stubs. - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - writable: true, - value: originalResizeObserver, - }); - } - }); - - it('lets the positioning context shrink so a tall form cannot push its action row off-screen', () => { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - const menu = container.querySelector( - '.model-selector', - ) as HTMLElement | null; - expect(menu).not.toBeNull(); - - const sharedWrapper = collectAncestors(menu as HTMLElement).find((el) => - /(^|\s)relative(\s|$)/.test(el.className), - ); - expect(sharedWrapper).toBeDefined(); - // The wrapper must NOT be flex-shrink-0: when the form grows taller - // than the webview (collapsed bottom panel, image previews, multi-line - // draft), this flex child has to give way so the form's bottom edge — - // and therefore its send/cancel/approval/model action row — stays - // pinned to the viewport bottom instead of overflowing below it with - // no scroll recovery (body overflow:hidden). The dropdown anchor is - // the measured form height (asserted above), not the wrapper's - // rendered height, so shrinking here cannot slide the anchor behind - // the opaque form. - expect(sharedWrapper?.className).not.toMatch(/(^|\s)flex-shrink-0(\s|$)/); - }); - - it('does not render the selector when showModelSelector is false', () => { - const rendered = renderInputForm({ - showModelSelector: false, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - expect(container.querySelector('.model-selector')).toBeNull(); - }); - - it('still selects a model on click and closes the selector', () => { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - const row = container.querySelector( - '[data-index="1"]', - ) as HTMLElement | null; - expect(row).not.toBeNull(); - - act(() => { - (row as HTMLElement).click(); - }); - - expect(rendered.onSelectModel).toHaveBeenCalledWith('model-b'); - expect(rendered.onCloseModelSelector).toHaveBeenCalledTimes(1); - }); - - it('still closes the selector on Escape', () => { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - }), - ); - }); - - expect(rendered.onCloseModelSelector).toHaveBeenCalledTimes(1); - expect(rendered.onSelectModel).not.toHaveBeenCalled(); - }); - - it('does not let the Escape that closes the selector keep propagating to the composer', () => { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - }); - root = rendered.root; - container = rendered.container; - - // A bubble-phase listener stands in for every downstream keydown handler - // (the webui composer's Escape branch → onCancel sits behind exactly - // this gate). The selector's capture-phase handler must stop the event, - // or the same Escape that closes the selector also cancels the - // in-flight generation. - const bubbleSpy = vi.fn(); - document.addEventListener('keydown', bubbleSpy); - - try { - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - }), - ); - }); - } finally { - document.removeEventListener('keydown', bubbleSpy); - } - - expect(rendered.onCloseModelSelector).toHaveBeenCalledTimes(1); - expect(bubbleSpy).not.toHaveBeenCalled(); - }); - - it('reports the open dropdown height for messages scroll clearance', () => { - const observers: Array<{ - callback: ResizeObserverCallback; - targets: Element[]; - }> = []; - const originalResizeObserver = globalThis.ResizeObserver; - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - value: class { - readonly targets: Element[] = []; - constructor(callback: ResizeObserverCallback) { - observers.push({ callback, targets: this.targets }); - } - observe(target: Element) { - this.targets.push(target); - } - unobserve() {} - disconnect() {} - }, - }); - - const onModelSelectorClearance = vi.fn(); - try { - const rendered = renderInputForm({ - showModelSelector: true, - availableModels: models, - currentModelId: null, - onModelSelectorClearance, - }); - root = rendered.root; - container = rendered.container; - - const menu = container.querySelector( - '.model-selector', - ) as HTMLElement | null; - expect(menu).not.toBeNull(); - - // The adapter must observe the dropdown's own positioned wrapper (the - // element that paints over the messages viewport) and report its - // measured height — that number becomes the messages container's - // bottom scroll clearance while the selector is open (#8617). - const dropdown = collectAncestors(menu as HTMLElement).find((el) => - /(^|\s)absolute(\s|$)/.test(el.className), - ); - expect(dropdown).toBeDefined(); - - const observed = observers.flatMap((entry) => entry.targets); - expect(observed).toContain(dropdown); - - Object.defineProperty(dropdown, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - height: 184, - width: 400, - top: 300, - bottom: 484, - left: 0, - right: 400, - x: 0, - y: 300, - toJSON: () => ({}), - }), - }); - - act(() => { - for (const entry of observers) { - entry.callback([], {} as ResizeObserver); - } - }); - - expect(onModelSelectorClearance).toHaveBeenCalledWith(184); - } finally { - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - writable: true, - value: originalResizeObserver, - }); - } - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx deleted file mode 100644 index 63760d2eb37..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * InputForm adapter for VSCode - wraps webui InputForm with local type handling - * This allows local ApprovalModeValue to work with webui's EditModeInfo - */ - -import type { ClipboardEvent, FC, ReactNode } from 'react'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { InputForm as BaseInputForm, getEditModeIcon } from '@qwen-code/webui'; -import type { - InputFormProps as BaseInputFormProps, - EditModeInfo, -} from '@qwen-code/webui'; -import type { CompletionItem } from '../../../types/completionItemTypes.js'; -import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; -import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import { ModelSelector } from './ModelSelector.js'; - -/** - * Extended props that accept ApprovalModeValue and ModelSelector - */ -export interface InputFormProps - extends Omit { - /** Edit mode value (local type) */ - editMode: ApprovalModeValue; - /** Optional paste handler forwarded to the base input */ - onPaste?: (e: ClipboardEvent) => void; - /** Optional content rendered between the input and actions */ - extraContent?: ReactNode; - /** Completion fill callback (Tab or equivalent) */ - onCompletionFill?: (item: CompletionItem) => void; - /** Whether to show model selector */ - showModelSelector?: boolean; - /** Available models for selection */ - availableModels?: ModelInfo[]; - /** Current model ID */ - currentModelId?: string | null; - /** Callback when a model is selected */ - onSelectModel?: (modelId: string) => void; - /** Callback to close model selector */ - onCloseModelSelector?: () => void; - /** - * Reports the open model-selector dropdown's measured height so the chat - * viewport can reserve bottom scroll clearance for it (issue #8617: the - * last message must stay revealable while the dropdown is open). - */ - onModelSelectorClearance?: (heightPx: number) => void; -} - -/** - * Convert ApprovalModeValue to EditModeInfo - */ -const getEditModeInfo = (editMode: ApprovalModeValue): EditModeInfo => { - const info = getApprovalModeInfoFromString(editMode); - - return { - label: info.label, - title: info.title, - icon: info.iconType ? getEditModeIcon(info.iconType) : null, - }; -}; - -/** - * InputForm with ApprovalModeValue and ModelSelector support - * - * This is an adapter that accepts the local ApprovalModeValue type - * and converts it to webui's EditModeInfo format. - * It also renders the ModelSelector component when needed. - */ -export const InputForm: FC = ({ - editMode, - showModelSelector, - availableModels, - currentModelId, - onSelectModel, - onCloseModelSelector, - onModelSelectorClearance, - ...rest -}) => { - const editModeInfo = getEditModeInfo(editMode); - const wrapperRef = useRef(null); - const dropdownRef = useRef(null); - const [formHeight, setFormHeight] = useState(0); - - // The base form's root is `absolute bottom-0 left-0 right-0` and out of - // flow, so the wrapper below would collapse to zero height and any - // `bottom-full` dropdown would anchor at the viewport bottom, behind the - // opaque form (issue #8617). Measure the form and give the wrapper its - // height so `bottom-full` clears the form's top edge. - useLayoutEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper || typeof ResizeObserver === 'undefined') { - return; - } - - // Find the wrapper child that contains the base form (the webui - // InputForm root) so the dropdown tracks the form's real height. - const form = wrapper.querySelector('form.composer-form'); - let node: HTMLElement | null = form instanceof HTMLElement ? form : null; - while (node && node.parentElement !== wrapper) { - node = node.parentElement; - } - if (!node) { - return; - } - const formRoot = node; - - const measure = () => { - setFormHeight(formRoot.getBoundingClientRect().height); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(formRoot); - return () => { - observer.disconnect(); - }; - }, []); - - // While the selector is open, report the dropdown's measured height so - // the chat viewport can reserve bottom scroll clearance for it — the - // dropdown paints over the messages viewport, and without clearance the - // last message's tail cannot be scrolled into view while the dropdown is - // open (issue #8617). - useLayoutEffect(() => { - const dropdown = dropdownRef.current; - if ( - !showModelSelector || - !dropdown || - !onModelSelectorClearance || - typeof ResizeObserver === 'undefined' - ) { - return; - } - const measure = () => { - onModelSelectorClearance(dropdown.getBoundingClientRect().height); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(dropdown); - return () => { - observer.disconnect(); - }; - }, [showModelSelector, onModelSelectorClearance]); - - return ( - // Positioning context for the ModelSelector. The base form's root is - // `absolute bottom-0 left-0 right-0` and out of flow (see - // packages/webui/src/components/layout/InputForm.tsx), so left alone - // this wrapper would collapse to zero height and the dropdown would - // anchor at the viewport bottom, behind the opaque form (issue #8617). - // The effect above sizes this wrapper to the form's measured height so - // the flex layout reserves the form's space in the chat column. - // - // The wrapper is deliberately NOT flex-shrink-0: when the form grows - // taller than the webview (collapsed bottom panel, image previews, - // multi-line draft), this child must give way so the form's bottom - // edge stays pinned to the viewport bottom and its action row remains - // reachable — a rigid wrapper pushes the action row below the viewport - // with no scroll recovery (body overflow:hidden). The dropdown anchor - // below uses the measured form height directly instead of this - // wrapper's rendered height, so shrinking here cannot slide the anchor - // back behind the opaque form. -
0 ? { height: `${formHeight}px` } : undefined} - > - {showModelSelector && onSelectModel && onCloseModelSelector && ( - // z-30 places this wrapper's stacking context above the local-message - // notices (z-20, see App.tsx) so the interactive dropdown rows paint - // over any coexisting notice, but still below the fixed overlays - // (z-[999]/z-[1000]: PermissionDrawer / AskUserQuestionDialog / - // AccountInfoDialog / SessionSelector). It also keeps ModelSelector's - // internal z-index (z-[1000]) inside this stacking context so the - // dropdown never escapes above those overlays; App closes the selector - // when an overlay takes over and never opens it underneath one. - // - // The anchor is the measured form height, not bottom-full (== this - // wrapper's rendered height): the form's bottom edge is pinned to - // this wrapper's bottom edge, so `formHeight` above the wrapper - // bottom is always the form's top edge — even when the wrapper - // shrinks in a short webview (issue #8617, both directions). - // bottom-full stays as the pre-measurement fallback. -
0 ? { bottom: `${formHeight}px` } : undefined} - className="absolute bottom-full left-4 right-4 mb-2 z-30 max-w-[600px] mx-auto" - > - -
- )} - -
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx deleted file mode 100644 index 1a8a264736a..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import { ModelSelector } from './ModelSelector.js'; - -vi.mock('@qwen-code/webui', () => ({ - PlanCompletedIcon: () => null, -})); - -interface RenderHandle { - container: HTMLDivElement; - root: Root; - onSelectModel: ReturnType; - onClose: ReturnType; -} - -const handles: RenderHandle[] = []; - -beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: vi.fn(), - }); -}); - -function renderModelSelector(props: { - models: ModelInfo[]; - currentModelId?: string | null; - visible?: boolean; -}): RenderHandle { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - const onSelectModel = vi.fn(); - const onClose = vi.fn(); - - act(() => { - root.render( - , - ); - }); - - const handle: RenderHandle = { container, root, onSelectModel, onClose }; - handles.push(handle); - return handle; -} - -afterEach(() => { - while (handles.length > 0) { - const handle = handles.pop()!; - act(() => { - handle.root.unmount(); - }); - handle.container.remove(); - } -}); - -const discontinuedModel: ModelInfo = { - modelId: 'qwen3-coder-plus(qwen-oauth)', - name: 'Qwen3 Coder Plus', - description: 'Original description should be replaced', -}; - -const runtimeOAuthModel: ModelInfo = { - modelId: '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', - name: 'Qwen3 Coder Plus (Runtime)', -}; - -const otherProviderModel: ModelInfo = { - modelId: 'gpt-4(openai)', - name: 'GPT-4', - description: 'OpenAI flagship', -}; - -describe('ModelSelector — discontinued state (Issue #3745)', () => { - it('renders the (Discontinued) badge for non-runtime Qwen OAuth models', () => { - const { container } = renderModelSelector({ - models: [discontinuedModel], - }); - const row = container.querySelector('[data-discontinued="true"]'); - expect(row).not.toBeNull(); - const badge = container.querySelector('[data-testid="discontinued-badge"]'); - expect(badge?.textContent).toBe('(Discontinued)'); - expect(row?.getAttribute('aria-disabled')).toBe('true'); - }); - - it('replaces description with the migration hint for discontinued models', () => { - const { container } = renderModelSelector({ - models: [discontinuedModel], - }); - expect(container.textContent).toContain( - 'Discontinued — switch to Coding Plan or API Key', - ); - expect(container.textContent).not.toContain( - 'Original description should be replaced', - ); - }); - - it('does NOT mark a runtime Qwen OAuth snapshot as discontinued', () => { - const { container } = renderModelSelector({ - models: [runtimeOAuthModel], - }); - expect(container.querySelector('[data-discontinued="true"]')).toBeNull(); - expect( - container.querySelector('[data-testid="discontinued-badge"]'), - ).toBeNull(); - }); - - it('blocks click selection on a discontinued model and surfaces an inline error', () => { - const { container, onSelectModel, onClose } = renderModelSelector({ - models: [discontinuedModel], - }); - const row = container.querySelector( - '[data-discontinued="true"]', - ) as HTMLElement; - expect(row).not.toBeNull(); - - act(() => { - row.click(); - }); - - expect(onSelectModel).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - const blocked = container.querySelector( - '[data-testid="model-selector-blocked"]', - ); - expect(blocked?.textContent).toContain( - 'Qwen OAuth free tier was discontinued on 2026-04-15', - ); - }); - - it('allows clicking a non-discontinued model exactly once', () => { - const { container, onSelectModel, onClose } = renderModelSelector({ - models: [otherProviderModel], - }); - const row = container.querySelector('[data-index="0"]') as HTMLElement; - act(() => { - row.click(); - }); - expect(onSelectModel).toHaveBeenCalledTimes(1); - expect(onSelectModel).toHaveBeenCalledWith('gpt-4(openai)'); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('keeps a runtime Qwen OAuth snapshot selectable', () => { - const { container, onSelectModel } = renderModelSelector({ - models: [runtimeOAuthModel], - }); - const row = container.querySelector('[data-index="0"]') as HTMLElement; - act(() => { - row.click(); - }); - expect(onSelectModel).toHaveBeenCalledWith(runtimeOAuthModel.modelId); - }); - - it('blocks the keyboard Enter path on a discontinued model', () => { - const { onSelectModel, onClose } = renderModelSelector({ - models: [discontinuedModel], - }); - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), - ); - }); - - expect(onSelectModel).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('clears a stale blocked message when hovering another row', () => { - const { container } = renderModelSelector({ - models: [discontinuedModel, otherProviderModel], - }); - const discontinuedRow = container.querySelector( - '[data-discontinued="true"]', - ) as HTMLElement; - const otherRow = container.querySelectorAll( - '[data-index]', - )[1] as HTMLElement; - - act(() => { - discontinuedRow.click(); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).not.toBeNull(); - - // React 19 synthesizes onMouseEnter from `mouseover` with boundary checks. - // Dispatching `mouseover` on the target row reliably triggers the React - // handler in jsdom; raw `mouseenter` does not bubble through the delegated - // listener. - act(() => { - otherRow.dispatchEvent( - new MouseEvent('mouseover', { bubbles: true, relatedTarget: null }), - ); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).toBeNull(); - }); - - it('clears a stale blocked message when navigating with ArrowDown / ArrowUp', () => { - const { container } = renderModelSelector({ - models: [discontinuedModel, otherProviderModel], - }); - const discontinuedRow = container.querySelector( - '[data-discontinued="true"]', - ) as HTMLElement; - - act(() => { - discontinuedRow.click(); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).not.toBeNull(); - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), - ); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).toBeNull(); - - // Re-trigger the banner, then verify ArrowUp also clears it. - act(() => { - discontinuedRow.click(); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).not.toBeNull(); - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }), - ); - }); - expect( - container.querySelector('[data-testid="model-selector-blocked"]'), - ).toBeNull(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx deleted file mode 100644 index a8feba496e1..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ /dev/null @@ -1,281 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { FC } from 'react'; -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import { PlanCompletedIcon } from '@qwen-code/webui'; -import { - DISCONTINUED_MESSAGES, - isDiscontinuedModel, -} from '../../utils/discontinuedModel.js'; - -interface ModelSelectorProps { - visible: boolean; - models: ModelInfo[]; - currentModelId: string | null; - onSelectModel: (modelId: string) => void; - onClose: () => void; -} - -export const ModelSelector: FC = ({ - visible, - models, - currentModelId, - onSelectModel, - onClose, -}) => { - const containerRef = useRef(null); - const [selected, setSelected] = useState(0); - const [mounted, setMounted] = useState(false); - const [blockedMessage, setBlockedMessage] = useState(null); - - // Reset selection when models change or when opened - useEffect(() => { - if (visible) { - // Find current model index or default to 0 - const currentIndex = models.findIndex( - (m) => m.modelId === currentModelId, - ); - setSelected(currentIndex >= 0 ? currentIndex : 0); - setMounted(true); - setBlockedMessage(null); - } else { - setMounted(false); - setBlockedMessage(null); - } - }, [visible, models, currentModelId]); - - const handleModelSelect = useCallback( - (modelId: string) => { - if (isDiscontinuedModel(modelId)) { - setBlockedMessage(DISCONTINUED_MESSAGES.blockedError); - return; - } - onSelectModel(modelId); - onClose(); - }, - [onSelectModel, onClose], - ); - - // Handle clicking outside to close and keyboard navigation - useEffect(() => { - if (!visible) { - return; - } - - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - onClose(); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - setSelected((prev) => Math.min(prev + 1, models.length - 1)); - // Clear stale block banner so keyboard navigation gives the same - // feedback as mouse hover. - setBlockedMessage(null); - break; - case 'ArrowUp': - event.preventDefault(); - setSelected((prev) => Math.max(prev - 1, 0)); - setBlockedMessage(null); - break; - case 'Enter': { - // Prevent form submission AND stop propagation so the input form - // does not treat this Enter as a message send. - event.preventDefault(); - event.stopPropagation(); - const target = models[selected]; - if (!target) { - break; - } - if (isDiscontinuedModel(target.modelId)) { - setBlockedMessage(DISCONTINUED_MESSAGES.blockedError); - break; - } - onSelectModel(target.modelId); - onClose(); - break; - } - case 'Escape': - // stopPropagation matches the Enter case above: the selector owns - // the keyboard while open. Without it this Escape keeps - // propagating into the composer's Escape branch (webui InputForm - // handleKeyDown → onCancel) and cancels the in-flight generation - // the user never asked to stop. - event.preventDefault(); - event.stopPropagation(); - onClose(); - break; - default: - break; - } - }; - - document.addEventListener('mousedown', handleClickOutside); - // Use capture phase so Enter is handled before bubble-phase handlers - // (e.g. the InputForm's Enter-to-submit) and stopPropagation can - // prevent an empty user message. - document.addEventListener('keydown', handleKeyDown, true); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown, true); - }; - }, [visible, models, selected, onSelectModel, onClose]); - - // Scroll selected item into view - useEffect(() => { - const selectedEl = containerRef.current?.querySelector( - `[data-index="${selected}"]`, - ); - if (selectedEl) { - selectedEl.scrollIntoView({ block: 'nearest' }); - } - }, [selected]); - - if (!visible) { - return null; - } - - return ( -
- {/* Header */} -
- Select a model -
- - {/* Inline blocked-selection error (cleared on hover or close) */} - {blockedMessage && ( -
- - {blockedMessage} -
- )} - - {/* Model list */} -
- {models.length === 0 ? ( -
- No models available. Check console for details. -
- ) : ( - models.map((model, index) => { - const isActive = index === selected; - const isCurrentModel = model.modelId === currentModelId; - const discontinued = isDiscontinuedModel(model.modelId); - const description = discontinued - ? DISCONTINUED_MESSAGES.description - : model.description; - return ( -
handleModelSelect(model.modelId)} - onMouseEnter={() => { - setSelected(index); - // Clear stale block message when hovering a different row so - // back-to-back attempts on different discontinued models still - // produce fresh feedback. - setBlockedMessage(null); - }} - className={[ - 'model-selector-item', - 'mx-1 rounded-[var(--app-list-border-radius)]', - discontinued - ? 'cursor-not-allowed opacity-60' - : 'cursor-pointer', - 'p-[var(--app-list-item-padding)]', - isActive ? 'bg-[var(--app-list-active-background)]' : '', - ].join(' ')} - > -
-
- - {model.name} - {discontinued && ( - - {DISCONTINUED_MESSAGES.badge} - - )} - - {description && ( - - {description} - - )} -
- {isCurrentModel && ( - - - - )} -
-
- ); - }) - )} -
-
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx deleted file mode 100644 index 31a20804ab0..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; - -vi.mock('./ProviderSetupForm.js', () => ({ - ProviderSetupForm: () => , -})); - -import { Onboarding } from './Onboarding.js'; - -describe('Onboarding', () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - - document.body.removeAttribute('data-extension-uri'); - - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root?.unmount(); - }); - container?.remove(); - root = null; - container = null; - }); - - it('renders the logo without requiring an extension URI on the body', () => { - act(() => { - root?.render(); - }); - - const logo = container?.querySelector('img[alt="Qwen Code"]'); - - expect(logo).toBeTruthy(); - expect(logo?.getAttribute('src')).toBeTruthy(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx deleted file mode 100644 index bd4691dffca..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * VSCode-specific Onboarding page. - * Vertically centered welcome card with provider setup trigger. - */ - -import type { FC } from 'react'; -// eslint-disable-next-line import/no-internal-modules -- bundle the webview logo as a data URL -import iconUrl from '../../../../assets/icon.png'; -import { ProviderSetupForm } from './ProviderSetupForm.js'; - -/** - * VSCode Onboarding page. - */ -export const Onboarding: FC = () => ( -
- {/* Logo + title block — sits above the card for visual breathing room */} -
- Qwen Code -
-

- Qwen Code -

-

- AI-powered coding assistant for your editor -

-
-
- - {/* Setup card */} -
-

- Connect a model provider to get started -

- -
- - {/* Subtle hint below the card */} -

- Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and - OpenAI-compatible endpoints -

-
-); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx deleted file mode 100644 index 970f0ec1acc..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; - -const { mockPostMessage } = vi.hoisted(() => ({ - mockPostMessage: vi.fn(), -})); - -vi.mock('../../hooks/useVSCode.js', () => ({ - useVSCode: () => ({ - postMessage: mockPostMessage, - getState: vi.fn(), - setState: vi.fn(), - }), -})); - -import { ProviderSetupForm } from './ProviderSetupForm.js'; - -describe('ProviderSetupForm', () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - vi.clearAllMocks(); - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root?.unmount(); - }); - container?.remove(); - root = null; - container = null; - }); - - it('leaves connecting state when auth flow is cancelled', () => { - act(() => { - root?.render(); - }); - - const button = container?.querySelector('button'); - expect(button).toBeTruthy(); - - act(() => { - button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(mockPostMessage).toHaveBeenCalledWith({ type: 'auth' }); - expect(container?.textContent).toContain('Connecting...'); - expect(button?.hasAttribute('disabled')).toBe(true); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'authCancelled' }, - }), - ); - }); - - expect(container?.textContent).toContain('Get Started'); - expect(button?.hasAttribute('disabled')).toBe(false); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx deleted file mode 100644 index 63d284e22de..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Provider Setup — triggers the auth interactive flow (QuickPick + InputBox). - */ - -import { useState, useEffect, type FC } from 'react'; -import { useVSCode } from '../../hooks/useVSCode.js'; - -/** - * Small rotating spinner for loading states. - */ -const Spinner: FC<{ size?: number }> = ({ size = 14 }) => ( - -); - -/** - * ProviderSetupForm — Single button that launches the interactive auth flow. - */ -export const ProviderSetupForm: FC = () => { - const vscode = useVSCode(); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const handler = (event: MessageEvent) => { - const msg = event.data; - if (msg?.type === 'authError' || msg?.type === 'agentConnectionError') { - setIsConnecting(false); - setError( - msg.data?.message || 'Connection failed. Check your settings.', - ); - } - if (msg?.type === 'authCancelled') { - setIsConnecting(false); - setError(null); - } - if (msg?.type === 'authSuccess' || msg?.type === 'agentConnected') { - setIsConnecting(false); - setError(null); - } - }; - window.addEventListener('message', handler); - return () => window.removeEventListener('message', handler); - }, []); - - const handleGetStarted = () => { - setError(null); - setIsConnecting(true); - vscode.postMessage({ type: 'auth' }); - }; - - return ( -
- - - {error && ( -
- {error} -
- )} -
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx deleted file mode 100644 index ac1fbce11bf..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Main ToolCall component - uses factory pattern to route to specialized components - * - * This file serves as the public API for tool call rendering. - * It re-exports the router and types from the toolcalls module. - */ - -import type { FC } from 'react'; -import type { ToolCallData } from '@qwen-code/webui'; -import { ToolCallRouter } from './index.js'; - -// Re-export types from webui for backward compatibility -export type { - ToolCallData, - BaseToolCallProps as ToolCallProps, - ToolCallContent, -} from '@qwen-code/webui'; - -export const ToolCall: FC<{ - toolCall: ToolCallData; - isFirst?: boolean; - isLast?: boolean; -}> = ({ toolCall, isFirst, isLast }) => ( - -); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx deleted file mode 100644 index 9e9359d76f2..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ToolCallRouter } from './index.js'; - -vi.mock('@qwen-code/webui', async () => { - const React = await vi.importActual('react'); - - // Use a data attribute to record which component was selected by the - // *real* routing logic, rather than maintaining a parallel mock router. - const renderLabel = (label: string) => - function MockTool(props: { - toolCall: { - title?: string; - rawOutput?: { - taskDescription?: string; - terminateReason?: string; - }; - }; - isFirst?: boolean; - isLast?: boolean; - }) { - return React.createElement( - 'div', - { - 'data-label': label, - 'data-is-first': props.isFirst, - 'data-is-last': props.isLast, - }, - `${label}:${props.toolCall.rawOutput?.taskDescription || props.toolCall.title || ''}:${props.toolCall.rawOutput?.terminateReason || ''}`, - ); - }; - - // Import the real routing function so the test validates actual routing - // rather than a manually-maintained parallel mock that can silently drift. - const { - getToolCallComponent: realGetToolCallComponent, - isAgentExecutionToolCall, - } = - await vi.importActual( - '@qwen-code/webui', - ); - - // Map each real component to its label-based mock. - const componentMocks: Record> = { - AgentToolCall: renderLabel('agent'), - GenericToolCall: renderLabel('generic'), - ReadToolCall: renderLabel('read'), - ShellToolCall: renderLabel('shell'), - ThinkToolCall: renderLabel('think'), - EditToolCall: renderLabel('edit'), - WriteToolCall: renderLabel('write'), - SearchToolCall: renderLabel('search'), - UpdatedPlanToolCall: renderLabel('plan'), - WebFetchToolCall: renderLabel('web'), - }; - - // Wrap getToolCallComponent to return the label-mock instead of the real - // component — the routing logic is real, only the rendering is mocked. - const getToolCallComponent = ( - toolCall: Parameters[0], - ) => { - const realComponent = realGetToolCallComponent(toolCall); - const componentName = realComponent.displayName || realComponent.name || ''; - return componentMocks[componentName] || componentMocks['GenericToolCall']!; - }; - - return { - shouldShowToolCall: () => true, - isAgentExecutionToolCall, - getToolCallComponent, - GenericToolCall: componentMocks['GenericToolCall'], - ThinkToolCall: componentMocks['ThinkToolCall'], - EditToolCall: componentMocks['EditToolCall'], - WriteToolCall: componentMocks['WriteToolCall'], - SearchToolCall: componentMocks['SearchToolCall'], - UpdatedPlanToolCall: componentMocks['UpdatedPlanToolCall'], - ShellToolCall: componentMocks['ShellToolCall'], - ReadToolCall: componentMocks['ReadToolCall'], - WebFetchToolCall: componentMocks['WebFetchToolCall'], - AgentToolCall: componentMocks['AgentToolCall'], - }; -}); - -describe('ToolCallRouter agent execution rendering', () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('renders a dedicated view for structured agent progress and summary', () => { - act(() => { - root?.render( - , - ); - }); - - expect(container?.textContent).toContain('agent:Explore auth logic'); - }); - - it('renders the agent failure reason from structured rawOutput', () => { - act(() => { - root?.render( - , - ); - }); - - expect(container?.textContent).toContain( - 'agent:Explore auth logic:Subagent crashed', - ); - }); - - it('forwards isFirst and isLast props to the underlying component', () => { - act(() => { - root?.render( - , - ); - }); - - const renderedDiv = container?.querySelector('div'); - expect(renderedDiv?.getAttribute('data-label')).toBe('read'); - expect(renderedDiv?.getAttribute('data-is-first')).toBe('true'); - expect(renderedDiv?.getAttribute('data-is-last')).toBe('false'); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx deleted file mode 100644 index a778de66062..00000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Tool call component factory - routes to specialized components by kind - * All UI components are now imported from @qwen-code/webui - */ - -import { shouldShowToolCall, getToolCallComponent } from '@qwen-code/webui'; -import type { FC } from 'react'; -import type { BaseToolCallProps } from '@qwen-code/webui'; - -/** - * Main tool call component that routes to specialized implementations - */ -export const ToolCallRouter: FC = ({ - toolCall, - isFirst, - isLast, -}) => { - // Check if we should show this tool call (hide internal ones) - if (!shouldShowToolCall(toolCall.kind)) { - return null; - } - - // Get the appropriate component for this kind - const Component = getToolCallComponent(toolCall); - - // Render the specialized component - return ; -}; - -// Re-export types for convenience -export type { BaseToolCallProps, ToolCallData } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx deleted file mode 100644 index 21d1178a7d5..00000000000 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * VSCode Platform Provider - Adapts VSCode API to PlatformContext - * This allows webui components to work with VSCode's messaging system - */ - -import { useMemo, useCallback, useEffect, useRef } from 'react'; -import type { FC, ReactNode } from 'react'; -import { PlatformProvider } from '@qwen-code/webui'; -import type { PlatformContextValue } from '@qwen-code/webui'; -import { useVSCode } from '../hooks/useVSCode.js'; -import { generateIconUrl } from '../utils/resourceUrl.js'; - -/** - * Props for VSCodePlatformProvider - */ -interface VSCodePlatformProviderProps { - children: ReactNode; -} - -interface PendingCopyRequest { - resolve: () => void; - reject: (error: Error) => void; - timeoutId: ReturnType; -} - -/** - * VSCodePlatformProvider - Provides platform context for VSCode extension - * - * This component bridges the VSCode API with the platform-agnostic webui components. - * It wraps children with PlatformProvider and provides VSCode-specific implementations. - */ -export const VSCodePlatformProvider: FC = ({ - children, -}) => { - const vscode = useVSCode(); - const messageHandlersRef = useRef void>>(new Set()); - const copyRequestCounterRef = useRef(0); - const pendingCopyRequestsRef = useRef>( - new Map(), - ); - - // Set up message listener - useEffect(() => { - const pendingCopyRequests = pendingCopyRequestsRef.current; - const handleMessage = (event: MessageEvent) => { - const message = event.data as - | { - type?: string; - data?: { - requestId?: string; - success?: boolean; - error?: string; - }; - } - | undefined; - - if (message?.type === 'copyToClipboardResult') { - const requestId = message.data?.requestId; - const pending = requestId - ? pendingCopyRequests.get(requestId) - : undefined; - if (!requestId || !pending) { - return; - } - - clearTimeout(pending.timeoutId); - pendingCopyRequests.delete(requestId); - if (message.data?.success) { - pending.resolve(); - } else { - pending.reject( - new Error(message.data?.error || 'Failed to copy to clipboard.'), - ); - } - return; - } - - messageHandlersRef.current.forEach((handler) => { - handler(event.data); - }); - }; - - window.addEventListener('message', handleMessage); - return () => { - window.removeEventListener('message', handleMessage); - pendingCopyRequests.forEach((pending) => { - clearTimeout(pending.timeoutId); - pending.reject(new Error('Copy request was interrupted.')); - }); - pendingCopyRequests.clear(); - }; - }, []); - - // Open file handler - const openFile = useCallback( - (path: string) => { - vscode.postMessage({ - type: 'openFile', - data: { path }, - }); - }, - [vscode], - ); - - // Open diff handler - const openDiff = useCallback( - ( - path: string, - oldText: string | null | undefined, - newText: string | undefined, - ) => { - vscode.postMessage({ - type: 'openDiff', - data: { - path, - oldText: oldText ?? '', - newText: newText ?? '', - }, - }); - }, - [vscode], - ); - - // Open temp file handler - const openTempFile = useCallback( - (content: string, fileName: string = 'temp') => { - vscode.postMessage({ - type: 'createAndOpenTempFile', - data: { - content, - fileName, - }, - }); - }, - [vscode], - ); - - // Attach file handler - const attachFile = useCallback(() => { - vscode.postMessage({ - type: 'attachFile', - data: {}, - }); - }, [vscode]); - - // Auth handler - const login = useCallback(() => { - vscode.postMessage({ - type: 'auth', - data: {}, - }); - }, [vscode]); - - // Copy to clipboard handler - const copyToClipboard = useCallback( - (text: string) => { - const requestId = `copy-${Date.now()}-${copyRequestCounterRef.current++}`; - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - pendingCopyRequestsRef.current.delete(requestId); - reject(new Error('Timed out copying to clipboard.')); - }, 10000); - - pendingCopyRequestsRef.current.set(requestId, { - resolve, - reject, - timeoutId, - }); - - try { - vscode.postMessage({ - type: 'copyToClipboard', - data: { text, requestId }, - }); - } catch (error) { - clearTimeout(timeoutId); - pendingCopyRequestsRef.current.delete(requestId); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }, - [vscode], - ); - - // Get resource URL handler (for icons and other assets) - const getResourceUrl = useCallback( - (resourceName: string) => generateIconUrl(resourceName) || undefined, - [], - ); - - // Subscribe to messages - const onMessage = useCallback((handler: (message: unknown) => void) => { - messageHandlersRef.current.add(handler); - return () => { - messageHandlersRef.current.delete(handler); - }; - }, []); - - // Build platform context value - const platformValue = useMemo( - () => ({ - platform: 'vscode', - postMessage: vscode.postMessage, - onMessage, - openFile, - openDiff, - openTempFile, - attachFile, - login, - copyToClipboard, - getResourceUrl, - features: { - canOpenFile: true, - canOpenDiff: true, - canOpenTempFile: true, - canAttachFile: true, - canLogin: true, - canCopy: true, - }, - }), - [ - vscode.postMessage, - onMessage, - openFile, - openDiff, - openTempFile, - attachFile, - login, - copyToClipboard, - getResourceUrl, - ], - ); - - return ( - - {children as React.ReactNode} - - ); -}; diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts index 71e72d052ec..2dc19f80340 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; import { FileMessageHandler } from './FileMessageHandler.js'; +import { registerNewCommands } from '../../commands/index.js'; import * as vscode from 'vscode'; const shouldIgnoreFileMock = vi.hoisted(() => vi.fn()); @@ -92,6 +93,10 @@ const vscodeMock = vi.hoisted(() => { }>, }, }, + commands: { + registerCommand: vi.fn(), + executeCommand: vi.fn(), + }, }; }); @@ -424,4 +429,46 @@ describe('FileMessageHandler', () => { expect(options.viewColumn).toBe(vscodeMock.ViewColumn.Beside); }); }); + + it('closeDiff resolves workspace-relative paths before closing the diff', async () => { + vscodeMock.workspace.workspaceFolders = [ + { uri: vscode.Uri.file('/workspace'), name: 'workspace', index: 0 }, + ]; + const registeredCommands = new Map< + string, + (...args: unknown[]) => unknown + >(); + vscodeMock.commands.registerCommand.mockImplementation( + (id: string, handler: (...args: unknown[]) => unknown) => { + registeredCommands.set(id, handler); + return { dispose: vi.fn() }; + }, + ); + vscodeMock.commands.executeCommand.mockImplementation( + async (id: string, ...args: unknown[]) => + registeredCommands.get(id)?.(...args), + ); + const closeDiff = vi.fn().mockResolvedValue(undefined); + registerNewCommands( + { subscriptions: [] } as never, + vi.fn(), + { showDiff: vi.fn(), closeDiff } as never, + () => [], + vi.fn() as never, + ); + + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + vi.fn(), + ); + + await handler.handle({ + type: 'closeDiff', + data: { path: 'src/foo.ts' }, + }); + + expect(closeDiff).toHaveBeenCalledWith('/workspace/src/foo.ts', true); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index 023f8541663..3722acd34bb 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -8,7 +8,7 @@ import { logger } from '../../utils/logger.js'; import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import { getFileName } from '../utils/webviewUtils.js'; -import { showDiffCommand } from '../../commands/index.js'; +import { closeDiffCommand, showDiffCommand } from '../../commands/index.js'; import { findLeftGroupOfChatWebview, findRightGroupOfChatWebview, @@ -57,6 +57,8 @@ export class FileMessageHandler extends BaseMessageHandler { 'getWorkspaceFiles', 'openFile', 'openDiff', + 'openDiffList', + 'closeDiff', 'createAndOpenTempFile', ].includes(messageType); } @@ -225,6 +227,17 @@ export class FileMessageHandler extends BaseMessageHandler { await this.handleOpenDiff(data); break; + case 'openDiffList': + await this.handleOpenDiffList(data); + break; + + case 'closeDiff': + await vscode.commands.executeCommand( + closeDiffCommand, + (data?.path as string) || '', + ); + break; + case 'createAndOpenTempFile': await this.handleCreateAndOpenTempFile(data); break; @@ -624,6 +637,55 @@ export class FileMessageHandler extends BaseMessageHandler { } } + private async handleOpenDiffList( + data: Record | undefined, + ): Promise { + const changes = Array.isArray(data?.changes) + ? (data.changes as Array>) + : []; + if (changes.length === 0) return; + + const selectedPath = data?.selectedPath as string | undefined; + let selected = selectedPath + ? changes.find((change) => change.path === selectedPath) + : undefined; + if (!selected) { + const items = changes.flatMap((change) => { + const path = typeof change.path === 'string' ? change.path : ''; + if (!path) return []; + const additions = Number(change.additions ?? 0); + const deletions = Number(change.deletions ?? 0); + return [ + { + label: getFileName(path), + description: path, + detail: `+${additions} −${deletions}`, + change, + }, + ]; + }); + selected = ( + await vscode.window.showQuickPick(items, { + title: 'Review changes', + placeHolder: 'Select a file to open its diff', + }) + )?.change; + } + if (!selected) return; + + const diffs = Array.isArray(selected.diffs) + ? (selected.diffs as Array>) + : []; + const latest = diffs[diffs.length - 1]; + const path = typeof selected.path === 'string' ? selected.path : ''; + if (!path || !latest) return; + await this.handleOpenDiff({ + path, + oldText: typeof latest.oldText === 'string' ? latest.oldText : '', + newText: typeof latest.newText === 'string' ? latest.newText : '', + }); + } + /** * Create and open temporary readonly file */ diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 921d51d5d63..d96c97497d4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -97,10 +97,6 @@ vi.mock('../../services/sessionExportService.js', () => ({ exportSessionToFile: mockExportSessionToFile, })); -vi.mock('@qwen-code/webui', () => ({ - stripZeroWidthSpaces: (text: string) => text.replace(/\u200B/g, ''), -})); - import { SessionMessageHandler } from './SessionMessageHandler.js'; import { MAX_IMAGE_SIZE } from '../../utils/imageSupport.js'; @@ -141,6 +137,115 @@ describe('SessionMessageHandler', () => { }); }); + it('sends inline file contents to ACP without exposing them in the user display', async () => { + mockProcessImageAttachments.mockImplementation( + async (promptText: string) => ({ + formattedText: promptText, + displayText: promptText, + savedImageCount: 0, + promptImages: [], + }), + ); + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + const conversationStore = { + createConversation: vi.fn().mockResolvedValue({ id: 'conversation-1' }), + getConversation: vi.fn().mockResolvedValue(null), + addMessage: vi.fn(), + renameConversationId: vi.fn().mockResolvedValue(true), + }; + const sendToWebView = vi.fn(); + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: 'Please inspect this file', + inlineFiles: [ + { + name: 'notes&<.md', + mediaType: 'text/markdown', + text: '# private contents', + }, + ], + }, + }); + + expect(agentManager.sendMessage).toHaveBeenCalledWith([ + { + type: 'text', + text: 'Please inspect this file\n\n\n# private contents\n', + }, + ]); + expect(conversationStore.addMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + role: 'user', + content: 'Please inspect this file', + }), + ); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'sessionTitleUpdated', + data: { sessionId: 'conversation-1', title: 'Please inspect this file' }, + }); + }); + + it('sends inline files when the user text is empty', async () => { + mockProcessImageAttachments.mockImplementation( + async (promptText: string) => ({ + formattedText: promptText, + displayText: promptText, + savedImageCount: 0, + promptImages: [], + }), + ); + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + const conversationStore = { + createConversation: vi.fn().mockResolvedValue({ id: 'conversation-1' }), + getConversation: vi.fn().mockResolvedValue(null), + addMessage: vi.fn(), + renameConversationId: vi.fn().mockResolvedValue(true), + }; + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + vi.fn(), + ); + + await handler.handle({ + type: 'sendMessage', + data: { + text: '', + inlineFiles: [{ name: 'empty.txt', mediaType: 'text/plain', text: '' }], + }, + }); + + expect(agentManager.sendMessage).toHaveBeenCalledWith([ + { + type: 'text', + text: '\n\n', + }, + ]); + expect(conversationStore.addMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ role: 'user', content: '' }), + ); + }); + it('does not create conversation state or send an empty prompt when all pasted images fail to materialize', async () => { const agentManager = { isConnected: true, diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index bc76a083917..06649ff3d5d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -10,6 +10,7 @@ import * as fsp from 'fs/promises'; import { pathToFileURL } from 'node:url'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; +import type { Conversation } from '../../services/conversationStore.js'; import { getDisplayableImageMimeType, MAX_IMAGE_SIZE, @@ -23,7 +24,7 @@ import { } from '../utils/imageHandler.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; -import { stripZeroWidthSpaces } from '@qwen-code/webui'; +import { stripZeroWidthSpaces } from '../../utils/inputPlaceholder.js'; import { exportSessionToFile, parseExportSlashCommand, @@ -33,6 +34,40 @@ import { DISCONTINUED_MESSAGES, isDiscontinuedModel, } from '../utils/discontinuedModel.js'; +import type { InlineFilePayload } from '../../types/webviewMessageTypes.js'; + +const INLINE_FILE_ATTRIBUTE_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeInlineFileAttribute(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => INLINE_FILE_ATTRIBUTE_ESCAPES[character] ?? character, + ); +} + +function appendInlineFiles( + promptText: string, + inlineFiles: readonly InlineFilePayload[], +): string { + if (inlineFiles.length === 0) { + return promptText; + } + + const fileBlocks = inlineFiles + .map( + (file) => + `\n${file.text}\n`, + ) + .join('\n\n'); + + return promptText.length > 0 ? `${promptText}\n\n${fileBlocks}` : fileBlocks; +} function formatExportSuccessMessage( formatLabel: string, @@ -57,6 +92,8 @@ export class SessionMessageHandler extends BaseMessageHandler { canHandle(messageType: string): boolean { return [ 'sendMessage', + 'editMessage', + 'exportSession', 'newQwenSession', 'switchQwenSession', 'getQwenSessions', @@ -105,6 +142,41 @@ export class SessionMessageHandler extends BaseMessageHandler { } | undefined, data?.attachments as ImageAttachment[] | undefined, + data?.inlineFiles as InlineFilePayload[] | undefined, + ); + break; + + case 'editMessage': + await this.handleSendMessage( + (data?.text as string) || '', + data?.context as + | Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + isImage?: boolean; + }> + | undefined, + data?.fileContext as + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined, + data?.attachments as ImageAttachment[] | undefined, + data?.inlineFiles as InlineFilePayload[] | undefined, + data?.targetTurnIndex as number | undefined, + ); + break; + + case 'exportSession': + await this.handleWebShellExport( + (data?.text as string) || '', + (data?.sessionId as string) || undefined, ); break; @@ -214,6 +286,67 @@ export class SessionMessageHandler extends BaseMessageHandler { this.currentStreamContent = ''; } + private async captureConversationSnapshot( + conversationId: string | null, + ): Promise { + if (!conversationId) return null; + + const conversation = + await this.conversationStore.getConversation(conversationId); + if (conversation) { + return { + ...conversation, + messages: conversation.messages.map((message) => ({ ...message })), + }; + } + + const getSessionMessages = ( + this.agentManager as { + getSessionMessages?: (sessionId: string) => Promise; + } + ).getSessionMessages; + if (!getSessionMessages) return null; + + const messages = await getSessionMessages.call( + this.agentManager, + conversationId, + ); + if (messages.length === 0) return null; + + const timestamps = messages.map((message) => message.timestamp); + const recoveredConversation: Conversation = { + id: conversationId, + title: messages.find((message) => message.role === 'user')?.content ?? '', + messages: messages.map((message) => ({ ...message })), + createdAt: Math.min(...timestamps), + updatedAt: Math.max(...timestamps), + }; + await this.conversationStore.upsertConversation(recoveredConversation); + return recoveredConversation; + } + + private async restoreConversationSnapshot( + snapshot: Conversation | null, + ): Promise { + if (!snapshot) return; + + const restored = await this.conversationStore.replaceMessages( + snapshot.id, + snapshot.messages, + ); + if (!restored) { + logger.warn( + '[SessionMessageHandler] Failed to restore conversation snapshot; conversation not found:', + snapshot.id, + ); + } + this.updateCurrentConversationId(snapshot.id); + this.sendToWebView({ + type: 'conversationLoaded', + data: { ...snapshot, restoreTranscript: true }, + }); + } + /** * Monotonically increasing request counter used to tag streamStart/streamEnd * so the WebView can detect and discard stale events from previous requests. @@ -340,11 +473,14 @@ export class SessionMessageHandler extends BaseMessageHandler { private async handleExportCommand( format: SessionExportFormat, + explicitSessionId?: string, ): Promise { // Prefer the active ACP session id. The local conversation id may still be // a webview-only `conv_*` placeholder after starting a fresh session. const sessionId = - this.agentManager.currentSessionId ?? this.currentConversationId; + explicitSessionId ?? + this.agentManager.currentSessionId ?? + this.currentConversationId; if (!sessionId) { const errorMsg = 'No active session found to export.'; this.sendToWebView({ @@ -377,6 +513,14 @@ export class SessionMessageHandler extends BaseMessageHandler { localOnly: true, }, }); + this.sendToWebView({ + type: 'exportCompleted', + data: { + format: formatLabel, + filename: result.filename, + filePath: result.uri.fsPath, + }, + }); } catch (error) { const errorMsg = this.getErrorMessage(error); logger.error('[SessionMessageHandler] Failed to export session:', error); @@ -387,6 +531,22 @@ export class SessionMessageHandler extends BaseMessageHandler { } } + private async handleWebShellExport( + text: string, + sessionId?: string, + ): Promise { + try { + const format = parseExportSlashCommand(text); + if (!format) return; + await this.handleExportCommand(format, sessionId); + } catch (error) { + this.sendToWebView({ + type: 'error', + data: { message: this.getErrorMessage(error) }, + }); + } + } + /** * Handle send message request */ @@ -407,6 +567,8 @@ export class SessionMessageHandler extends BaseMessageHandler { endLine?: number; }, attachments?: ImageAttachment[], + inlineFiles?: InlineFilePayload[], + editTargetTurnIndex?: number, ): Promise { logger.log('[SessionMessageHandler] handleSendMessage called', { textLength: text.length, @@ -417,7 +579,8 @@ export class SessionMessageHandler extends BaseMessageHandler { // or model-selector interactions clear the input but still trigger a submit. const trimmedText = stripZeroWidthSpaces(text).trim(); const hasAttachments = (attachments?.length ?? 0) > 0; - if (!trimmedText && !hasAttachments) { + const hasInlineFiles = (inlineFiles?.length ?? 0) > 0; + if (!trimmedText && !hasAttachments && !hasInlineFiles) { logger.warn('[SessionMessageHandler] Ignoring empty message'); return; } @@ -471,6 +634,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } } promptText = formattedText; + promptText = appendInlineFiles(promptText, inlineFiles ?? []); displayText = updatedDisplayText; if (hasAttachments && !trimmedText && savedImageCount === 0) { @@ -529,22 +693,91 @@ export class SessionMessageHandler extends BaseMessageHandler { return; } + let editRestoreSnapshot: Conversation | null = null; + let editStoreMutationApplied = false; + let editAcpMutationApplied = false; + let editAcpHistorySnapshot: unknown[] | null = null; + + if (editTargetTurnIndex !== undefined) { + if (!Number.isInteger(editTargetTurnIndex) || editTargetTurnIndex < 0) { + this.sendToWebView({ + type: 'error', + data: { message: 'Invalid message edit target.' }, + }); + return; + } + if (!this.agentManager.isConnected) { + await this.promptAuth( + 'You need to configure your provider to use Qwen Code.', + ); + return; + } + + try { + editRestoreSnapshot = await this.captureConversationSnapshot( + this.currentConversationId, + ); + if (editRestoreSnapshot) { + const truncated = await this.conversationStore.truncateFromUserTurn( + this.currentConversationId, + editTargetTurnIndex, + ); + if (!truncated) { + throw new Error('Conversation not found for edit target.'); + } + editStoreMutationApplied = true; + } + + const rewindResult = + await this.agentManager.rewindSession(editTargetTurnIndex); + editAcpHistorySnapshot = rewindResult?.historyBeforeRewind ?? null; + editAcpMutationApplied = true; + const retainedConversation = + await this.conversationStore.getConversation( + this.currentConversationId, + ); + this.sendToWebView({ + type: 'conversationRewound', + data: { + targetTurnIndex: editTargetTurnIndex, + sessionId: this.agentManager.currentSessionId, + messages: retainedConversation?.messages ?? [], + }, + }); + } catch (error) { + if (editAcpMutationApplied && editAcpHistorySnapshot) { + await this.agentManager.restoreSessionHistory(editAcpHistorySnapshot); + } + if (editStoreMutationApplied) { + await this.restoreConversationSnapshot(editRestoreSnapshot); + } + const errorMsg = this.getErrorMessage(error); + vscode.window.showErrorMessage(`Failed to edit message: ${errorMsg}`); + this.sendToWebView({ type: 'error', data: { message: errorMsg } }); + return; + } + } + // Check if this is the first message let isFirstMessage = false; - try { - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - isFirstMessage = !conversation || conversation.messages.length === 0; - } catch (error) { - logger.error( - '[SessionMessageHandler] Failed to check conversation:', - error, - ); + if (editTargetTurnIndex !== undefined) { + isFirstMessage = editTargetTurnIndex === 0; + } else { + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + isFirstMessage = !conversation || conversation.messages.length === 0; + } catch (error) { + logger.error( + '[SessionMessageHandler] Failed to check conversation:', + error, + ); + } } // Generate title for first message, but only if it hasn't been set yet - if (isFirstMessage && !this.isTitleSet) { + if (isFirstMessage && (!this.isTitleSet || editTargetTurnIndex === 0)) { this.sendToWebView({ type: 'sessionTitleUpdated', data: { @@ -573,8 +806,17 @@ export class SessionMessageHandler extends BaseMessageHandler { error, ); + if (editAcpMutationApplied && editAcpHistorySnapshot) { + await this.agentManager.restoreSessionHistory(editAcpHistorySnapshot); + } + if (editStoreMutationApplied) { + await this.restoreConversationSnapshot(editRestoreSnapshot); + } + const errorMsg = this.getErrorMessage(error); - vscode.window.showErrorMessage(`Failed to send message: ${errorMsg}`); + vscode.window.showErrorMessage( + `${editTargetTurnIndex === undefined ? 'Failed to send' : 'Failed to edit'} message: ${errorMsg}`, + ); this.sendToWebView({ type: 'error', data: { message: errorMsg }, @@ -761,6 +1003,13 @@ export class SessionMessageHandler extends BaseMessageHandler { } catch (error) { logger.error('[SessionMessageHandler] Error sending message:', error); + if (editAcpMutationApplied && editAcpHistorySnapshot) { + await this.agentManager.restoreSessionHistory(editAcpHistorySnapshot); + } + if (editStoreMutationApplied) { + await this.restoreConversationSnapshot(editRestoreSnapshot); + } + const err = error as unknown as Error; // Safely convert error to string const errorMsg = this.getErrorMessage(error); diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts deleted file mode 100644 index 50344ac0efe..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback, useRef } from 'react'; -import type { VSCodeAPI } from '../../hooks/useVSCode.js'; - -/** - * File context management Hook - * Manages active file, selection content, and workspace file list - */ -export const useFileContext = (vscode: VSCodeAPI) => { - const [activeFileName, setActiveFileName] = useState(null); - const [activeFilePath, setActiveFilePath] = useState(null); - const [activeSelection, setActiveSelection] = useState<{ - startLine: number; - endLine: number; - } | null>(null); - - const [workspaceFiles, setWorkspaceFiles] = useState< - Array<{ - id: string; - label: string; - description: string; - path: string; - }> - >([]); - - // File reference mapping: @filename -> full path - const fileReferenceMap = useRef>(new Map()); - - // Whether workspace files have been requested - const hasRequestedFilesRef = useRef(false); - - // Use request ids to avoid applying stale workspace file responses. - const workspaceFilesRequestIdRef = useRef(0); - const latestWorkspaceFilesRequestIdRef = useRef(null); - - // Last non-empty query to decide when to refetch full list - const lastQueryRef = useRef(undefined); - - // Search debounce timer - const searchTimerRef = useRef(null); - - /** - * Request workspace files - */ - const requestWorkspaceFiles = useCallback( - (query?: string) => { - const normalizedQuery = query?.trim(); - const normalizedQueryKey = normalizedQuery?.toLowerCase(); - - // If there's a query, clear previous timer and set up debounce - if (normalizedQuery && normalizedQuery.length >= 1) { - if (normalizedQueryKey === lastQueryRef.current) { - return; - } - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - - const requestId = workspaceFilesRequestIdRef.current + 1; - workspaceFilesRequestIdRef.current = requestId; - latestWorkspaceFilesRequestIdRef.current = requestId; - - searchTimerRef.current = setTimeout(() => { - vscode.postMessage({ - type: 'getWorkspaceFiles', - data: { query: normalizedQuery, requestId }, - }); - }, 300); - lastQueryRef.current = normalizedQueryKey; - } else { - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - searchTimerRef.current = null; - } - - // For empty query, request once initially and whenever we are returning from a search - const shouldRequestFullList = - !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; - - if (shouldRequestFullList) { - const requestId = workspaceFilesRequestIdRef.current + 1; - workspaceFilesRequestIdRef.current = requestId; - latestWorkspaceFilesRequestIdRef.current = requestId; - lastQueryRef.current = undefined; - hasRequestedFilesRef.current = true; - vscode.postMessage({ - type: 'getWorkspaceFiles', - data: { requestId }, - }); - } - } - }, - [vscode], - ); - - /** - * Apply workspace file responses only if they are current. - */ - const setWorkspaceFilesFromResponse = useCallback( - ( - files: Array<{ - id: string; - label: string; - description: string; - path: string; - }>, - requestId?: number, - ) => { - if ( - typeof requestId === 'number' && - latestWorkspaceFilesRequestIdRef.current !== requestId - ) { - return; - } - setWorkspaceFiles(files); - }, - [], - ); - - /** - * Add file reference (called when user selects a file from completion) - * Also resets the last query so that backspacing and re-typing will trigger a fresh search - */ - const addFileReference = useCallback((fileName: string, filePath: string) => { - fileReferenceMap.current.set(fileName, filePath); - lastQueryRef.current = undefined; - }, []); - - /** - * Get file reference - */ - const getFileReference = useCallback( - (fileName: string) => fileReferenceMap.current.get(fileName), - [], - ); - - /** - * Clear file references - */ - const clearFileReferences = useCallback(() => { - fileReferenceMap.current.clear(); - }, []); - - /** - * Request active editor info - */ - const requestActiveEditor = useCallback(() => { - vscode.postMessage({ type: 'getActiveEditor', data: {} }); - }, [vscode]); - - /** - * Focus on active editor - */ - const focusActiveEditor = useCallback(() => { - vscode.postMessage({ - type: 'focusActiveEditor', - data: {}, - }); - }, [vscode]); - - return { - // State - activeFileName, - activeFilePath, - activeSelection, - workspaceFiles, - hasRequestedFiles: hasRequestedFilesRef.current, - - // State setters - setActiveFileName, - setActiveFilePath, - setActiveSelection, - setWorkspaceFiles, - setWorkspaceFilesFromResponse, - - // File reference operations - addFileReference, - getFileReference, - clearFileReferences, - - // Operations - requestWorkspaceFiles, - requestActiveEditor, - focusActiveEditor, - }; -}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.test.tsx deleted file mode 100644 index 85a89c29543..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useMessageHandling, type TextMessage } from './useMessageHandling.js'; - -type MessageHandlingApi = ReturnType; - -function renderHookHarness() { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - let latestApi: MessageHandlingApi | null = null; - - function Harness() { - latestApi = useMessageHandling(); - return null; - } - - act(() => { - root.render(); - }); - - return { - container, - root, - get api(): MessageHandlingApi { - if (!latestApi) { - throw new Error('Hook API is not available'); - } - return latestApi; - }, - }; -} - -/** - * The webview merges text messages and tool calls and sorts them by - * `timestamp` for rendering. Two known bugs push that sort in opposite - * directions: - * - * - Tool-call interleave: a tool call that arrives between two assistant - * segments of the same turn must sort strictly between them. This - * requires seg1.ts < toolCall.ts < seg2.ts. - * - * - #3273 (user question appears above the previous assistant answer): - * a user message belonging to a later turn must sort after every - * segment / tool call of the previous turn, even if the later segment - * was created after the user message was added. This requires all - * segments of turn N to be strictly less than any message/tool call - * from turn N+1. - * - * A single timestamp strategy cannot satisfy both simultaneously without a - * monotonic-sequence layer. These tests pin the current behaviour of the - * hook so we can wire a proper fix in next. - */ -describe('useMessageHandling', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - }); - - afterEach(() => { - vi.useRealTimers(); - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('toggles the waiting flag without exposing write-only loading text', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - expect(rendered.api.isWaitingForResponse).toBe(false); - // The waiting-message renderer was removed with the WebShell transcript - // migration; only the boolean flag (submit gating / cancel) may survive. - expect(rendered.api).not.toHaveProperty('loadingMessage'); - - act(() => { - rendered.api.setWaitingForResponse(); - }); - expect(rendered.api.isWaitingForResponse).toBe(true); - expect(rendered.api).not.toHaveProperty('loadingMessage'); - - act(() => { - rendered.api.clearWaitingForResponse(); - }); - expect(rendered.api.isWaitingForResponse).toBe(false); - }); - - it('assigns the second assistant segment a newer timestamp so a tool call can sort between the two segments', () => { - vi.useFakeTimers(); - vi.setSystemTime(1_000); - - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.startStreaming(1_000); - }); - - act(() => { - rendered.api.appendStreamChunk('seg1'); - }); - - // Tool call arrives and triggers a segment break. - vi.setSystemTime(2_000); - const toolCallTimestamp = Date.now(); - act(() => { - rendered.api.breakAssistantSegment(); - }); - - // Next chunk lands after the tool call. - vi.setSystemTime(3_000); - act(() => { - rendered.api.appendStreamChunk('seg2'); - }); - - const assistantMessages = rendered.api.messages.filter( - (message): message is TextMessage => message.role === 'assistant', - ); - - expect(assistantMessages).toHaveLength(2); - expect(assistantMessages[0].timestamp).toBeLessThan(toolCallTimestamp); - expect(assistantMessages[1].timestamp).toBeGreaterThan(toolCallTimestamp); - }); - - it('keeps thinking after the user message when streamStart shares the same timestamp', () => { - vi.useFakeTimers(); - vi.setSystemTime(1_000); - - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.addMessage({ - role: 'user', - content: 'edited prompt', - timestamp: 1_000, - }); - }); - - act(() => { - rendered.api.startStreaming(1_000); - }); - - act(() => { - rendered.api.appendThinkingChunk('thinking'); - }); - - const sorted = [...rendered.api.messages].sort( - (a, b) => a.timestamp - b.timestamp, - ); - - expect(sorted.map((message) => message.role)).toEqual([ - 'user', - 'thinking', - 'assistant', - ]); - }); - - it.fails( - 'keeps every assistant segment of a turn before a user message that was sent between segments (#3273)', - () => { - // Reproduces the race that #3273 describes: React batching (or any - // other delay) causes the second segment's placeholder to materialize - // AFTER the next user message has already been pushed into the list. - // Because the placeholder uses Date.now() at materialization time, it - // receives a timestamp greater than the new user message, and the - // user bubble ends up sandwiched between the two assistant segments - // once the list is sorted. - vi.useFakeTimers(); - vi.setSystemTime(1_000); - - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.startStreaming(1_000); - }); - - act(() => { - rendered.api.appendStreamChunk('seg1'); - }); - - vi.setSystemTime(2_000); - act(() => { - rendered.api.breakAssistantSegment(); - }); - - // The user types and sends their next question before the delayed - // second-segment chunk is flushed. - vi.setSystemTime(3_000); - act(() => { - rendered.api.addMessage({ - role: 'user', - content: 'next question', - timestamp: Date.now(), - }); - }); - - // Second-segment chunk finally runs. - vi.setSystemTime(4_000); - act(() => { - rendered.api.appendStreamChunk('seg2'); - }); - - const sorted = [...rendered.api.messages].sort( - (a, b) => a.timestamp - b.timestamp, - ); - const roles = sorted.map((m) => m.role); - const userIdx = roles.indexOf('user'); - const lastAssistantIdx = roles.lastIndexOf('assistant'); - - // Expected (and currently violated) invariant: the user message marks - // the start of a new turn, so every assistant segment of the previous - // turn must come before it. - expect(lastAssistantIdx).toBeLessThan(userIdx); - }, - ); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts deleted file mode 100644 index c8af16a6544..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useRef, useCallback } from 'react'; - -export interface TextMessage { - role: 'user' | 'assistant' | 'thinking'; - content: string; - timestamp: number; - turnIndex?: number; - /** - * True for messages generated inside the webview itself (connection / - * auth / generic errors, the local "Interrupted" cancel mark). The - * WebShell transcript renders ACP `transcriptUpdate` frames only, so the - * App renders these entries in a dedicated notice slot. - */ - localOnly?: boolean; - kind?: 'image'; - imagePath?: string; - imageSrc?: string; - imageMissing?: boolean; - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }; -} - -/** - * Message handling Hook - * Manages message list, streaming responses, and loading state - */ -export const useMessageHandling = () => { - const [messages, setMessages] = useState([]); - const [isStreaming, setIsStreaming] = useState(false); - const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); - // Track the index of the assistant placeholder message during streaming - const streamingMessageIndexRef = useRef(null); - // Track the index of the current aggregated thinking message - const thinkingMessageIndexRef = useRef(null); - - /** - * Add message - */ - const addMessage = useCallback((message: TextMessage) => { - setMessages((prev) => [...prev, message]); - }, []); - - /** - * Clear messages - */ - const clearMessages = useCallback(() => { - setMessages([]); - }, []); - - /** - * Start streaming response - */ - const startStreaming = useCallback((timestamp?: number) => { - // Create an assistant placeholder message immediately so tool calls won't jump before it - setMessages((prev) => { - // Record index of the placeholder to update on chunks - streamingMessageIndexRef.current = prev.length; - const maxExistingTimestamp = prev.reduce( - (max, message) => Math.max(max, message.timestamp || 0), - 0, - ); - const placeholderTimestamp = Math.max( - typeof timestamp === 'number' ? timestamp : Date.now(), - maxExistingTimestamp + 2, - ); - return [ - ...prev, - { - role: 'assistant', - content: '', - timestamp: placeholderTimestamp, - }, - ]; - }); - setIsStreaming(true); - }, []); - - /** - * Add stream chunk - */ - const appendStreamChunk = useCallback( - (chunk: string) => { - // Ignore late chunks after user cancelled streaming (until next streamStart) - if (!isStreaming) { - return; - } - - setMessages((prev) => { - let idx = streamingMessageIndexRef.current; - const next = prev.slice(); - - // If there is no active placeholder (e.g., after a tool call), start a new one - if (idx === null) { - idx = next.length; - streamingMessageIndexRef.current = idx; - next.push({ role: 'assistant', content: '', timestamp: Date.now() }); - } - - if (idx < 0 || idx >= next.length) { - return prev; - } - const target = next[idx]; - next[idx] = { ...target, content: (target.content || '') + chunk }; - return next; - }); - }, - [isStreaming], - ); - - /** - * Break current assistant stream segment (e.g., when a tool call starts/updates) - * Next incoming chunk will create a new assistant placeholder - */ - const breakAssistantSegment = useCallback(() => { - streamingMessageIndexRef.current = null; - }, []); - - const breakThinkingSegment = useCallback(() => { - thinkingMessageIndexRef.current = null; - }, []); - - /** - * End streaming response - */ - const endStreaming = useCallback(() => { - setIsStreaming(false); - streamingMessageIndexRef.current = null; - thinkingMessageIndexRef.current = null; - }, []); - - /** - * Set waiting for response state - */ - const setWaitingForResponse = useCallback(() => { - setIsWaitingForResponse(true); - }, []); - - /** - * Clear waiting for response state - */ - const clearWaitingForResponse = useCallback(() => { - setIsWaitingForResponse(false); - }, []); - - return { - // State - messages, - isStreaming, - isWaitingForResponse, - - // Operations - addMessage, - clearMessages, - startStreaming, - appendStreamChunk, - endStreaming, - // Thought handling - appendThinkingChunk: (chunk: string) => { - // Ignore late thoughts after user cancelled streaming - if (!isStreaming) { - return; - } - setMessages((prev) => { - let idx = thinkingMessageIndexRef.current; - const next = prev.slice(); - if (idx === null) { - idx = next.length; - thinkingMessageIndexRef.current = idx; - // Use a timestamp just before the assistant placeholder so thinking - // sorts above the response text when messages are ordered by time. - const assistantIdx = streamingMessageIndexRef.current; - const assistantTs = - assistantIdx !== null && - assistantIdx >= 0 && - assistantIdx < next.length - ? next[assistantIdx].timestamp - : Date.now(); - next.push({ - role: 'thinking', - content: '', - timestamp: assistantTs - 1, - }); - } - if (idx >= 0 && idx < next.length) { - const target = next[idx]; - next[idx] = { ...target, content: (target.content || '') + chunk }; - } - return next; - }); - }, - clearThinking: () => { - thinkingMessageIndexRef.current = null; - }, - breakAssistantSegment, - breakThinkingSegment, - setWaitingForResponse, - clearWaitingForResponse, - setMessages, - }; -}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts deleted file mode 100644 index 2b863cd45b3..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import type { VSCodeAPI } from '../../hooks/useVSCode.js'; - -/** - * Session management Hook - * Manages session list, current session, session switching, and search - */ -export const useSessionManagement = (vscode: VSCodeAPI) => { - const [qwenSessions, setQwenSessions] = useState< - Array> - >([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [currentSessionTitle, setCurrentSessionTitle] = - useState('Past Conversations'); - const [showSessionSelector, setShowSessionSelector] = useState(false); - const [sessionSearchQuery, setSessionSearchQuery] = useState(''); - const [nextCursor, setNextCursor] = useState(undefined); - const [hasMore, setHasMore] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [isSwitchingSession, setIsSwitchingSessionRaw] = - useState(false); - const switchTimeoutRef = useRef | null>(null); - - const SWITCH_TIMEOUT_MS = 15000; - const PAGE_SIZE = 20; - - const setIsSwitchingSession = useCallback((value: boolean) => { - setIsSwitchingSessionRaw(value); - if (switchTimeoutRef.current) { - clearTimeout(switchTimeoutRef.current); - switchTimeoutRef.current = null; - } - if (value) { - switchTimeoutRef.current = setTimeout(() => { - console.warn( - '[useSessionManagement] Switch session timed out, clearing loading state', - ); - setIsSwitchingSessionRaw(false); - switchTimeoutRef.current = null; - }, SWITCH_TIMEOUT_MS); - } - }, []); - - useEffect( - () => () => { - if (switchTimeoutRef.current) { - clearTimeout(switchTimeoutRef.current); - switchTimeoutRef.current = null; - } - }, - [], - ); - - /** - * Filter session list - */ - const filteredSessions = useMemo(() => { - if (!sessionSearchQuery.trim()) { - return qwenSessions; - } - const query = sessionSearchQuery.toLowerCase(); - return qwenSessions.filter((session) => { - const title = ( - (session.title as string) || - (session.name as string) || - '' - ).toLowerCase(); - return title.includes(query); - }); - }, [qwenSessions, sessionSearchQuery]); - - /** - * Load session list - */ - const handleLoadQwenSessions = useCallback(() => { - // Reset pagination state and load first page - setQwenSessions([]); - setNextCursor(undefined); - setHasMore(true); - setIsLoading(true); - vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } }); - setShowSessionSelector(true); - }, [vscode]); - - const handleLoadMoreSessions = useCallback(() => { - if (!hasMore || isLoading || nextCursor === undefined) { - return; - } - setIsLoading(true); - vscode.postMessage({ - type: 'getQwenSessions', - data: { cursor: nextCursor, size: PAGE_SIZE }, - }); - }, [hasMore, isLoading, nextCursor, vscode]); - - /** - * Create new session - */ - const handleNewQwenSession = useCallback( - (modelId?: string | null) => { - const trimmedModelId = - typeof modelId === 'string' && modelId.trim().length > 0 - ? modelId.trim() - : undefined; - vscode.postMessage({ - type: 'openNewChatTab', - data: trimmedModelId ? { modelId: trimmedModelId } : {}, - }); - setShowSessionSelector(false); - }, - [vscode], - ); - - /** - * Switch session - */ - const handleSwitchSession = useCallback( - (sessionId: string) => { - if (sessionId === currentSessionId) { - console.log('[useSessionManagement] Already on this session, ignoring'); - setShowSessionSelector(false); - return; - } - - console.log('[useSessionManagement] Switching to session:', sessionId); - setIsSwitchingSession(true); - vscode.postMessage({ - type: 'switchQwenSession', - data: { sessionId }, - }); - }, - [currentSessionId, vscode, setIsSwitchingSession], - ); - - /** - * Delete session - */ - const handleDeleteSession = useCallback( - (sessionId: string) => { - vscode.postMessage({ - type: 'deleteQwenSession', - data: { sessionId }, - }); - }, - [vscode], - ); - - /** - * Rename session - */ - const handleRenameSession = useCallback( - (sessionId: string, title: string) => { - vscode.postMessage({ - type: 'renameQwenSession', - data: { sessionId, title }, - }); - }, - [vscode], - ); - - return { - // State - qwenSessions, - currentSessionId, - currentSessionTitle, - showSessionSelector, - sessionSearchQuery, - filteredSessions, - nextCursor, - hasMore, - isLoading, - isSwitchingSession, - - // State setters - setQwenSessions, - setCurrentSessionId, - setCurrentSessionTitle, - setShowSessionSelector, - setSessionSearchQuery, - setNextCursor, - setHasMore, - setIsLoading, - setIsSwitchingSession, - - // Operations - handleLoadQwenSessions, - handleNewQwenSession, - handleSwitchSession, - handleLoadMoreSessions, - handleDeleteSession, - handleRenameSession, - }; -}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.test.ts deleted file mode 100644 index 6b230f9ab28..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.test.ts +++ /dev/null @@ -1,740 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act, createElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { useAcpTranscript } from './useAcpTranscript.js'; - -function userTextNotification( - sessionId: string, - text: string, -): SessionNotification { - return { - sessionId, - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text }, - }, - }; -} - -function assistantTextNotification( - sessionId: string, - text: string, -): SessionNotification { - return { - sessionId, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text }, - }, - }; -} - -function toolCallNotification( - sessionId: string, - toolCallId: string, -): SessionNotification { - return { - sessionId, - update: { - sessionUpdate: 'tool_call', - toolCallId, - status: 'in_progress', - title: 'Running command', - kind: 'execute', - }, - } as SessionNotification; -} - -function postToWebview(message: unknown): void { - window.dispatchEvent(new MessageEvent('message', { data: message })); -} - -describe('useAcpTranscript', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - let captured: { blocks: ReturnType }; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - captured = { blocks: [] }; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - function Harness() { - captured.blocks = useAcpTranscript(); - return null; - } - - act(() => { - root?.render(createElement(Harness)); - }); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('reduces transcriptUpdate messages into rendered blocks', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'hello '), - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'world'), - }); - }); - - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ - kind: 'user', - text: 'hello world', - }); - }); - - it('resets transcript state when qwenSessionSwitched arrives between sessions', () => { - // Session A replays its own user text. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'alpha'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - // Session boundary: the extension clears the UI before replaying the - // newly-selected session through ACP. - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { sessionId: 'session-b', messages: [] }, - }); - }); - expect(captured.blocks).toHaveLength(0); - - // Session B's replay must not merge with session A's leftover state. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'beta'), - }); - }); - - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'beta' }); - }); - - it('resets transcript state when a new session clears the conversation', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'alpha'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - act(() => { - postToWebview({ type: 'conversationCleared', data: {} }); - }); - expect(captured.blocks).toHaveLength(0); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'beta'), - }); - }); - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'beta' }); - }); - - it('drops frames of the abandoned session after conversationCleared publishes the fresh id', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'old turn'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - // New-session flow: the extension creates the fresh ACP session first - // and publishes its id with the boundary. - act(() => { - postToWebview({ - type: 'conversationCleared', - data: { sessionId: 'session-b' }, - }); - }); - expect(captured.blocks).toHaveLength(0); - - // The abandoned session may still be streaming on the CLI; its - // trailing frames must not be adopted into the fresh conversation. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification( - 'session-a', - 'STALE tail of old session', - ), - }); - }); - expect(captured.blocks).toHaveLength(0); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'fresh'), - }); - }); - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'fresh' }); - }); - - it('keeps the guard pinned when conversationLoaded carries the session id', () => { - act(() => { - postToWebview({ - type: 'conversationCleared', - data: { sessionId: 'session-b' }, - }); - }); - - // First send of the new session: conversationLoaded resets the state - // but re-pins the guard via the carried session id, so a stale frame - // racing the boundary is still dropped. - act(() => { - postToWebview({ - type: 'conversationLoaded', - data: { id: 'conv_1', messages: [], sessionId: 'session-b' }, - }); - }); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification( - 'session-a', - 'STALE tail of old session', - ), - }); - }); - expect(captured.blocks).toHaveLength(0); - - // The new session's echo and reply render normally. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'hello'), - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-b', 'reply'), - }); - }); - expect(captured.blocks).toHaveLength(2); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'hello' }); - expect(captured.blocks[1]).toMatchObject({ - kind: 'assistant', - text: 'reply', - }); - }); - - it('resets transcript state when conversationLoaded arrives on reconnect', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'alpha'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - // Agent reconnect initialises an empty conversation and only posts - // conversationLoaded; the previous session's blocks must not survive. - act(() => { - postToWebview({ - type: 'conversationLoaded', - data: { id: 'temp', messages: [] }, - }); - }); - expect(captured.blocks).toHaveLength(0); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'beta'), - }); - }); - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'beta' }); - }); - - it('drops late transcript frames from a previous session after a switch', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'first'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { sessionId: 'session-b', messages: [] }, - }); - }); - expect(captured.blocks).toHaveLength(0); - - // Session A's turn is still running on the CLI and emits a trailing - // frame after the boundary; it must not contaminate session B. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'late tail from A'), - }); - }); - expect(captured.blocks).toHaveLength(0); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-b', 'beta'), - }); - }); - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'beta' }); - }); - - it('seeds the transcript from cached messages carried by qwenSessionSwitched', () => { - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { - sessionId: 'session-cached', - messages: [ - { role: 'user', content: 'cached question', timestamp: 1 }, - { role: 'assistant', content: 'cached answer', timestamp: 2 }, - { role: 'thinking', content: 'cached thought', timestamp: 3 }, - ], - }, - }); - }); - - expect(captured.blocks).toHaveLength(3); - expect(captured.blocks[0]).toMatchObject({ - kind: 'user', - text: 'cached question', - }); - expect(captured.blocks[1]).toMatchObject({ - kind: 'assistant', - text: 'cached answer', - }); - expect(captured.blocks[2]).toMatchObject({ - kind: 'thought', - text: 'cached thought', - }); - - // History restores are completed turns; sessionLoadComplete finalizes - // the last block so it does not keep streaming. - act(() => { - postToWebview({ - type: 'sessionLoadComplete', - data: { sessionId: 'session-cached' }, - }); - }); - expect(captured.blocks[2]).toMatchObject({ streaming: false }); - }); - - it('renders live frames of the fresh session published by a load-failure fallback', () => { - // session/load failed for an archived session: the extension falls back - // to cached history plus a fresh ACP session and publishes the fresh id - // as liveSessionId alongside the archived sessionId. - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { - sessionId: 'archived-session', - liveSessionId: 'fresh-acp-session', - messages: [ - { role: 'user', content: 'cached question', timestamp: 1 }, - { role: 'assistant', content: 'cached answer', timestamp: 2 }, - ], - }, - }); - }); - - expect(captured.blocks).toHaveLength(2); - expect(captured.blocks[0]).toMatchObject({ - kind: 'user', - text: 'cached question', - }); - expect(captured.blocks[1]).toMatchObject({ - kind: 'assistant', - text: 'cached answer', - }); - - // The extension posts sessionLoadComplete right after the boundary to - // finalize the cached history before the user interacts. - act(() => { - postToWebview({ - type: 'sessionLoadComplete', - data: { sessionId: 'archived-session' }, - }); - }); - expect(captured.blocks).toHaveLength(2); - expect(captured.blocks[1]).toMatchObject({ streaming: false }); - - // Live frames of the fresh session (user echo + assistant reply) must - // render even though the boundary's sessionId named the archived one. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('fresh-acp-session', 'follow-up'), - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('fresh-acp-session', 'live answer'), - }); - }); - - expect(captured.blocks).toHaveLength(4); - expect(captured.blocks[2]).toMatchObject({ - kind: 'user', - text: 'follow-up', - }); - expect(captured.blocks[3]).toMatchObject({ - kind: 'assistant', - text: 'live answer', - }); - - // Frames from unrelated sessions must still be dropped by the guard. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('unrelated-session', 'stray'), - }); - }); - expect(captured.blocks).toHaveLength(4); - }); - - it('finalizes the streaming assistant block when the turn ends', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'hi'), - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'answer'), - }); - }); - - const assistant = captured.blocks.find((b) => b.kind === 'assistant'); - expect(assistant).toMatchObject({ kind: 'assistant', streaming: true }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn' }, - }); - }); - - const finished = captured.blocks.find((b) => b.kind === 'assistant'); - expect(finished).toMatchObject({ - kind: 'assistant', - text: 'answer', - streaming: false, - }); - }); - - it('finalizes blocks with a cancelled reason when the user cancels', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'partial'), - }); - }); - expect(captured.blocks[0]).toMatchObject({ streaming: true }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); - }); - expect(captured.blocks[0]).toMatchObject({ streaming: false }); - }); - - it.each(['timeout', 'session_expired'] as const)( - 'force-finalizes an in-flight tool block when the stream ends with %s', - (reason) => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: toolCallNotification('session-a', 'call-1'), - }); - }); - expect(captured.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'in_progress', - }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason }, - }); - }); - - // The reducer only propagates abnormal termination for the - // cancelled/error reasons; without the mapping the tool block would - // keep its in-flight status and spin forever. - expect(captured.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'cancelled', - }); - }, - ); - - it('force-finalizes an in-flight tool block when the user cancels', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: toolCallNotification('session-a', 'call-1'), - }); - }); - expect(captured.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'in_progress', - }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); - }); - expect(captured.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'cancelled', - }); - }); - - it('leaves an in-flight tool block running on a normal end_turn stream end', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: toolCallNotification('session-a', 'call-1'), - }); - }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn' }, - }); - }); - - // Transport-layer-style endings must not cancel in-flight tools; the - // daemon still delivers the real terminal status via tool_call_update. - expect(captured.blocks[0]).toMatchObject({ - kind: 'tool', - status: 'in_progress', - }); - }); - - it('drops a stale untagged streamEnd while a tagged stream is active', () => { - act(() => { - postToWebview({ - type: 'streamStart', - data: { timestamp: Date.now(), requestId: 'req-1' }, - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'first half '), - }); - }); - - // A foreign/stale turn-end (e.g. the abandoned previous request's - // streamEnd) arrives mid-stream; finalizing here would split the - // in-flight answer into two assistant blocks. - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); - }); - - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ - kind: 'assistant', - text: 'first half ', - streaming: true, - }); - - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'second half'), - }); - }); - - // The reply stays in a single block instead of rendering split. - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ - kind: 'assistant', - text: 'first half second half', - streaming: true, - }); - - // The matching tagged streamEnd still finalizes the turn. - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn', requestId: 'req-1' }, - }); - }); - expect(captured.blocks[0]).toMatchObject({ streaming: false }); - }); - - it('drops a streamEnd tagged for a different request while a tagged stream is active', () => { - act(() => { - postToWebview({ - type: 'streamStart', - data: { timestamp: Date.now(), requestId: 'req-2' }, - }); - }); - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'answer'), - }); - }); - - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn', requestId: 'req-1' }, - }); - }); - - expect(captured.blocks[0]).toMatchObject({ streaming: true }); - }); - - it('ignores background-notification end-turns so they do not finalize the live turn', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: assistantTextNotification('session-a', 'streaming'), - }); - }); - expect(captured.blocks[0]).toMatchObject({ streaming: true }); - - // Background-task completion posts an end-turn (untagged, with the - // background_notification source) while the interactive turn streams. - act(() => { - postToWebview({ - type: 'streamEnd', - data: { - timestamp: Date.now(), - reason: 'end_turn', - source: 'background_notification', - }, - }); - }); - - expect(captured.blocks[0]).toMatchObject({ streaming: true }); - - // The live turn still finalizes on its own untagged end-turn when no - // tagged stream is active. - act(() => { - postToWebview({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn' }, - }); - }); - expect(captured.blocks[0]).toMatchObject({ streaming: false }); - }); - - it('does not seed the transcript when qwenSessionSwitched carries no messages field', () => { - const errors: Event[] = []; - const onError = (event: Event) => { - errors.push(event); - }; - window.addEventListener('error', onError); - try { - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { sessionId: 'session-no-messages' }, - }); - }); - - // The boundary still resets the transcript, but nothing is seeded - // and the handler must not crash on the missing cache array. - expect(captured.blocks).toHaveLength(0); - expect(errors).toHaveLength(0); - } finally { - window.removeEventListener('error', onError); - } - }); - - it('does not clobber the fresh transcript when qwenSessionSwitched carries an empty messages array', () => { - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-a', 'alpha'), - }); - }); - expect(captured.blocks).toHaveLength(1); - - act(() => { - postToWebview({ - type: 'qwenSessionSwitched', - data: { sessionId: 'session-empty-cache', messages: [] }, - }); - }); - expect(captured.blocks).toHaveLength(0); - - // Live frames of the switched session render after the boundary. - act(() => { - postToWebview({ - type: 'transcriptUpdate', - data: userTextNotification('session-empty-cache', 'live'), - }); - }); - expect(captured.blocks).toHaveLength(1); - expect(captured.blocks[0]).toMatchObject({ kind: 'user', text: 'live' }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.ts b/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.ts deleted file mode 100644 index ad8ca796704..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useAcpTranscript.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useEffect, useRef, useState } from 'react'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { - createDaemonTranscriptState, - reduceDaemonTranscriptEvents, - selectTranscriptBlocks, -} from '@qwen-code/sdk/daemon'; -import type { DaemonTranscriptState } from '@qwen-code/sdk/daemon'; -import { - cachedMessageToNotification, - reduceSessionNotification, -} from '../adapters/acpTranscriptAdapter.js'; - -/** Map webview `streamEnd` reasons onto the reducer's done reasons. */ -function streamEndToDoneReason(reason: unknown): string { - if (reason === 'user_cancelled') { - return 'cancelled'; - } - // Timeouts and expired sessions terminate the turn at the application - // layer; map them onto the reducer's abnormal-reason set so in-flight - // tool blocks are force-finalized instead of spinning forever. - if (reason === 'timeout' || reason === 'session_expired') { - return 'error'; - } - return typeof reason === 'string' && reason.length > 0 ? reason : 'end_turn'; -} - -/** - * Reduce `transcriptUpdate` webview messages into a shared-SDK transcript - * state and expose the rendered blocks. - * - * The transcript state resets on every session boundary used by the rest of - * the webview message flow (`qwenSessionSwitched` before an ACP replay, - * `conversationCleared` for a new session, and `conversationLoaded` on - * startup/reconnect), so blocks from one session can never leak into another - * session's replay. Frames arriving after a boundary are additionally - * dropped when their `sessionId` no longer matches the active session. - * Boundaries that publish a session id pin the guard to it, so trailing - * frames from the just-abandoned session cannot be adopted into the fresh - * conversation; a `liveSessionId` (load-failure fallbacks that create a - * fresh ACP session) wins over the archived `sessionId` so the fresh - * session's live frames pass the guard. Boundaries without an id fall back - * to adopting the first frame's session id. - */ -export function useAcpTranscript() { - const stateRef = useRef(null); - const activeSessionIdRef = useRef(null); - // Track the active requestId from the latest streamStart so stale or - // untagged streamEnd events can be dropped (mirrors useWebViewMessages). - const activeRequestIdRef = useRef(null); - const [blocks, setBlocks] = useState(() => - selectTranscriptBlocks(createDaemonTranscriptState()), - ); - - useEffect(() => { - const resetTranscript = () => { - stateRef.current = null; - activeSessionIdRef.current = null; - activeRequestIdRef.current = null; - setBlocks(selectTranscriptBlocks(createDaemonTranscriptState())); - }; - - /** Finalize in-flight assistant/thought blocks at a turn boundary. */ - const finishTurn = (reason: unknown) => { - if (stateRef.current === null) { - return; - } - stateRef.current = reduceDaemonTranscriptEvents(stateRef.current, [ - { type: 'assistant.done', reason: streamEndToDoneReason(reason) }, - ]); - setBlocks(selectTranscriptBlocks(stateRef.current)); - }; - - const handleMessage = (event: MessageEvent) => { - const message = event.data as { - type?: string; - data?: unknown; - }; - if ( - message?.type === 'qwenSessionSwitched' || - message?.type === 'conversationCleared' || - message?.type === 'conversationLoaded' - ) { - resetTranscript(); - const data = message.data as - | { - sessionId?: unknown; - liveSessionId?: unknown; - messages?: Array>; - } - | undefined; - // Boundaries that publish a session id pin the transcript guard to - // it: trailing frames from the just-abandoned session (which may - // still be streaming on the CLI) are dropped by the guard below - // instead of being blindly adopted into the fresh conversation. - // Load-failure fallbacks keep the archived conversation id for the - // session list while creating a fresh ACP session for live - // streaming; the boundary publishes that fresh id as - // `liveSessionId`, which wins over the archived `sessionId` so the - // live frames carrying it are not dropped (cached history is - // seeded under the same adopted id). - if (typeof data?.liveSessionId === 'string' && data.liveSessionId) { - activeSessionIdRef.current = data.liveSessionId; - } else if (typeof data?.sessionId === 'string' && data.sessionId) { - activeSessionIdRef.current = data.sessionId; - } - if (message.type === 'qwenSessionSwitched') { - // Offline restores and load-failure fallbacks deliver cached - // history here and never replay it through `transcriptUpdate`, - // so seed the transcript from the cached rows directly. - if (Array.isArray(data?.messages) && data.messages.length > 0) { - let state = createDaemonTranscriptState(); - for (const cached of data.messages) { - const notification = cachedMessageToNotification( - cached, - activeSessionIdRef.current ?? '', - ); - if (notification) { - state = reduceSessionNotification(state, notification); - } - } - stateRef.current = state; - setBlocks(selectTranscriptBlocks(state)); - } - } - return; - } - if (message?.type === 'streamStart') { - const startData = message.data as - | { timestamp?: number; requestId?: string } - | undefined; - activeRequestIdRef.current = startData?.requestId ?? null; - return; - } - if (message?.type === 'streamEnd') { - const endData = message.data as - | { reason?: unknown; requestId?: string; source?: string } - | undefined; - // Background-task completions emit end-turn notifications while - // an interactive turn may be mid-stream; they must not finalize - // (and thereby split or force-cancel) the live turn. - if (endData?.source === 'background_notification') { - return; - } - const endRequestId = endData?.requestId ?? null; - // Mirror useWebViewMessages: while a tagged stream is active, - // drop stale or untagged streamEnd events so a foreign turn-end - // cannot finalize the in-flight answer early — the next delta - // would otherwise start a second assistant block, and abnormal - // reasons would force-cancel genuinely running tool blocks. - if ( - activeRequestIdRef.current !== null && - endRequestId !== activeRequestIdRef.current - ) { - return; - } - activeRequestIdRef.current = null; - finishTurn(endData?.reason); - return; - } - if (message?.type === 'sessionLoadComplete') { - // History replays (and cached restores) have no turn-end frame of - // their own; finalize so the last block does not stay streaming. - finishTurn('end_turn'); - return; - } - if (message?.type !== 'transcriptUpdate' || !message.data) { - return; - } - const notification = message.data as SessionNotification; - // Drop late frames from a previous session that arrive after the - // boundary reset; adopt the session id on the first frame when the - // boundary did not carry one. - if (activeSessionIdRef.current === null) { - if ( - typeof notification.sessionId === 'string' && - notification.sessionId - ) { - activeSessionIdRef.current = notification.sessionId; - } - } else if (notification.sessionId !== activeSessionIdRef.current) { - return; - } - if (stateRef.current === null) { - stateRef.current = createDaemonTranscriptState(); - } - stateRef.current = reduceSessionNotification( - stateRef.current, - notification, - ); - setBlocks(selectTranscriptBlocks(stateRef.current)); - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, []); - - return blocks; -} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.test.tsx deleted file mode 100644 index 855fdaaa668..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act, useRef } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { useCompletionTrigger } from './useCompletionTrigger.js'; -import { ModelSelector } from '../components/layout/ModelSelector.js'; - -const completionItems: CompletionItem[] = [ - { id: 'compact', label: '/compact', type: 'command', value: 'compact' }, -]; - -const models: ModelInfo[] = [ - { modelId: 'model-a', name: 'Model A' }, - { modelId: 'model-b', name: 'Model B' }, -]; - -interface HarnessProps { - selectorOpen: boolean; - getCompletionItems: ( - trigger: '@' | '/', - query: string, - ) => Promise; - onSelectModel: (modelId: string) => void; - onCloseSelector: () => void; -} - -/** - * Mirrors the App wiring: the composer input drives useCompletionTrigger, - * the real ModelSelector mounts while "showModelSelector" is true, and - * showModelSelector is passed to the hook as its suppression flag. The - * invariant under test: at most one key-consuming menu may be mounted, so - * the visible top menu always owns the keyboard. - */ -function MenusHarness({ - selectorOpen, - getCompletionItems, - onSelectModel, - onCloseSelector, -}: HarnessProps) { - const inputRef = useRef(null); - const completion = useCompletionTrigger( - inputRef, - getCompletionItems, - selectorOpen, - ); - - return ( -
-
- {completion.isOpen &&
} - {selectorOpen && ( - - )} -
- ); -} - -let root: Root | null = null; -let container: HTMLDivElement | null = null; - -beforeEach(() => { - vi.clearAllMocks(); - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: vi.fn(), - }); - Object.defineProperty(Range.prototype, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - right: 0, - bottom: 0, - left: 0, - toJSON: () => ({}), - }), - }); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } -}); - -function mountHarness(props: HarnessProps): HTMLDivElement { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render(); - }); - return container; -} - -function rerenderHarness(props: HarnessProps) { - act(() => { - root?.render(); - }); -} - -/** Type `text` into the composer and fire the input event the hook listens to. */ -async function typeText(text: string) { - const input = container?.querySelector( - '[data-testid="composer-input"]', - ) as HTMLDivElement | null; - if (!input) { - throw new Error('Composer input not found'); - } - - act(() => { - input.textContent = text; - const textNode = input.firstChild; - if (!textNode) { - throw new Error('Missing text node'); - } - const selection = window.getSelection(); - const range = document.createRange(); - range.setStart(textNode, text.length); - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - await act(async () => { - input.dispatchEvent(new Event('input', { bubbles: true })); - await Promise.resolve(); - }); -} - -describe('completion suppression while the model selector is open', () => { - it('never mounts the completion menu next to an open model selector', async () => { - const getCompletionItems = vi.fn().mockResolvedValue(completionItems); - const el = mountHarness({ - selectorOpen: true, - getCompletionItems, - onSelectModel: vi.fn(), - onCloseSelector: vi.fn(), - }); - - // The probe shape from the review: with the selector open the composer - // keeps focus, and typing '/' re-opened the completion menu underneath - // the selector's capture-phase keydown listener. With the gate in place - // the two menus can no longer coexist. - await typeText('/'); - - expect(el.querySelector('[data-testid="completion-menu"]')).toBeNull(); - expect(el.querySelector('.model-selector')).not.toBeNull(); - expect(getCompletionItems).not.toHaveBeenCalled(); - }); - - it('leaves Enter owned by the visible menu (the selector) while suppressed', async () => { - const onSelectModel = vi.fn(); - const onCloseSelector = vi.fn(); - mountHarness({ - selectorOpen: true, - getCompletionItems: vi.fn().mockResolvedValue(completionItems), - onSelectModel, - onCloseSelector, - }); - - await typeText('/'); - - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - }), - ); - }); - - // Exactly one menu is mounted, so Enter selects its highlighted row — - // no hidden-menu divergence and no unrequested completion selection. - expect(onSelectModel).toHaveBeenCalledTimes(1); - expect(onSelectModel).toHaveBeenCalledWith('model-a'); - expect(onCloseSelector).toHaveBeenCalledTimes(1); - }); - - it('still opens the completion menu when the selector is closed', async () => { - const getCompletionItems = vi.fn().mockResolvedValue(completionItems); - const el = mountHarness({ - selectorOpen: false, - getCompletionItems, - onSelectModel: vi.fn(), - onCloseSelector: vi.fn(), - }); - - await typeText('/'); - - expect(el.querySelector('[data-testid="completion-menu"]')).not.toBeNull(); - expect(getCompletionItems).toHaveBeenCalledWith('/', ''); - }); - - it('closes an open completion menu when the selector opens', async () => { - const props: HarnessProps = { - selectorOpen: false, - getCompletionItems: vi.fn().mockResolvedValue(completionItems), - onSelectModel: vi.fn(), - onCloseSelector: vi.fn(), - }; - const el = mountHarness(props); - - await typeText('/'); - expect(el.querySelector('[data-testid="completion-menu"]')).not.toBeNull(); - - rerenderHarness({ ...props, selectorOpen: true }); - - expect(el.querySelector('[data-testid="completion-menu"]')).toBeNull(); - expect(el.querySelector('.model-selector')).not.toBeNull(); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts deleted file mode 100644 index c474e4d7e22..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { RefObject } from 'react'; -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { shouldAllowCompletionQuery } from '../utils/slashCommandUtils.js'; -import { resolveCompletionTrigger } from '../utils/completionUtils.js'; -import { stripZeroWidthSpaces } from '@qwen-code/webui'; - -interface CompletionTriggerState { - isOpen: boolean; - triggerChar: '@' | '/' | null; - query: string; - position: { top: number; left: number }; - items: CompletionItem[]; -} - -/** - * Hook to handle @ and / completion triggers in contentEditable - * Based on vscode-copilot-chat's AttachContextAction - */ -export function useCompletionTrigger( - inputRef: RefObject, - getCompletionItems: ( - trigger: '@' | '/', - query: string, - ) => Promise, - /** - * While suppressed the completion menu cannot open (and an open menu is - * closed). App sets this while the model selector is open: both menus - * anchor over the same area, and the selector's capture-phase document - * keydown listener would otherwise consume Enter for a menu the user may - * not even see. At most one key-consuming menu may be mounted at a time. - */ - isSuppressed = false, -) { - // Show immediate loading and provide a timeout fallback for slow sources - const LOADING_ITEM = useMemo( - () => ({ - id: 'loading', - label: 'Loading…', - type: 'info', - }), - [], - ); - - const TIMEOUT_ITEM = useMemo( - () => ({ - id: 'timeout', - label: 'Timeout', - type: 'info', - }), - [], - ); - const TIMEOUT_MS = 5000; - - const [state, setState] = useState({ - isOpen: false, - triggerChar: null, - query: '', - position: { top: 0, left: 0 }, - items: [], - }); - const stateRef = useRef(state); - - // Timer for loading timeout - const timeoutRef = useRef | null>(null); - // Track request order so slower responses can't overwrite newer completions. - const requestIdRef = useRef(0); - - useEffect(() => { - stateRef.current = state; - }, [state]); - - // Keep the suppression flag in a ref so openCompletion (a stable callback) - // can consult the latest value without re-registering listeners. - const suppressedRef = useRef(isSuppressed); - useEffect(() => { - suppressedRef.current = isSuppressed; - }, [isSuppressed]); - - const closeCompletion = useCallback(() => { - // Clear pending timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - requestIdRef.current += 1; - setState({ - isOpen: false, - triggerChar: null, - query: '', - position: { top: 0, left: 0 }, - items: [], - }); - }, []); - - // If suppression turns on while the menu is open (e.g. the model selector - // was just opened), close it so at most one key-consuming menu remains. - useEffect(() => { - if (isSuppressed) { - closeCompletion(); - } - }, [isSuppressed, closeCompletion]); - - const openCompletion = useCallback( - async ( - trigger: '@' | '/', - query: string, - position: { top: number; left: number }, - ) => { - // Gate every open path (typing a trigger and the programmatic opens - // for the command menu / skills picker) on suppression so completion - // can never coexist with the model selector. - if (suppressedRef.current) { - return; - } - - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - // Clear previous timeout if any - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - // Open immediately with a loading placeholder - setState({ - isOpen: true, - triggerChar: trigger, - query, - position, - items: [LOADING_ITEM], - }); - - // Schedule a timeout fallback if loading takes too long - timeoutRef.current = setTimeout(() => { - if (requestIdRef.current !== requestId) { - return; - } - setState((prev) => { - // Only show timeout if still open and still for the same request - if ( - prev.isOpen && - prev.triggerChar === trigger && - prev.query === query && - prev.items.length > 0 && - prev.items[0]?.id === 'loading' - ) { - return { ...prev, items: [TIMEOUT_ITEM] }; - } - return prev; - }); - }, TIMEOUT_MS); - - const items = await getCompletionItems(trigger, query); - if (requestIdRef.current !== requestId) { - return; - } - - // Clear timeout on success - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - setState((prev) => ({ - ...prev, - isOpen: true, - triggerChar: trigger, - query, - position, - items, - })); - }, - [getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM], - ); - - // Helper function to compare completion items arrays - const areItemsEqual = ( - items1: CompletionItem[], - items2: CompletionItem[], - ): boolean => { - if (items1.length !== items2.length) { - return false; - } - - // Compare each item by stable fields (ignore non-deterministic props like icons) - for (let i = 0; i < items1.length; i++) { - const a = items1[i]; - const b = items2[i]; - if (a.id !== b.id) { - return false; - } - if (a.label !== b.label) { - return false; - } - if ((a.description ?? '') !== (b.description ?? '')) { - return false; - } - if (a.type !== b.type) { - return false; - } - if ((a.value ?? '') !== (b.value ?? '')) { - return false; - } - if ((a.path ?? '') !== (b.path ?? '')) { - return false; - } - } - - return true; - }; - - const refreshCompletion = useCallback(async () => { - const currentState = stateRef.current; - if (!currentState.isOpen || !currentState.triggerChar) { - return; - } - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - const items = await getCompletionItems( - currentState.triggerChar, - currentState.query, - ); - if (requestIdRef.current !== requestId) { - return; - } - - // Only update state if items have actually changed - setState((prev) => { - if (areItemsEqual(prev.items, items)) { - return prev; - } - return { ...prev, items }; - }); - }, [getCompletionItems]); - - useEffect(() => { - const inputElement = inputRef.current; - if (!inputElement) { - return; - } - - const getCursorPosition = (): { top: number; left: number } | null => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - try { - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); - - // If the range has a valid position, use it - if (rect.top > 0 && rect.left > 0) { - return { - top: rect.top, - left: rect.left, - }; - } - - // Fallback: use input element's position - const inputRect = inputElement.getBoundingClientRect(); - return { - top: inputRect.top, - left: inputRect.left, - }; - } catch (error) { - console.error( - '[useCompletionTrigger] Error getting cursor position:', - error, - ); - const inputRect = inputElement.getBoundingClientRect(); - return { - top: inputRect.top, - left: inputRect.left, - }; - } - }; - - const handleInput = async () => { - // Strip zero-width space placeholders before processing, consistent - // with InputForm's onInput handler that strips them for React state. - const text = stripZeroWidthSpaces(inputElement.textContent || ''); - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - console.log('[useCompletionTrigger] No selection or rangeCount === 0'); - return; - } - - const range = selection.getRangeAt(0); - - // Get cursor position more reliably - // For contentEditable, we need to calculate the actual text offset - let cursorPosition = text.length; // Default to end of text - - if (range.startContainer === inputElement) { - // Cursor is directly in the container (e.g., empty or at boundary) - // Use childNodes to determine position - const childIndex = range.startOffset; - let offset = 0; - for ( - let i = 0; - i < childIndex && i < inputElement.childNodes.length; - i++ - ) { - offset += inputElement.childNodes[i].textContent?.length || 0; - } - // Preserve a legitimate offset of 0 (cursor at the container start); - // only fall back to text.length when the container has no children and - // the offset can't be computed from childNodes. - cursorPosition = - childIndex > 0 || inputElement.childNodes.length > 0 - ? offset - : text.length; - } else if (range.startContainer.nodeType === Node.TEXT_NODE) { - // Cursor is in a text node - calculate offset from start of input - const walker = document.createTreeWalker( - inputElement, - NodeFilter.SHOW_TEXT, - null, - ); - - let offset = 0; - let found = false; - let node: Node | null = walker.nextNode(); - while (node) { - if (node === range.startContainer) { - offset += range.startOffset; - found = true; - break; - } - offset += node.textContent?.length || 0; - node = walker.nextNode(); - } - // If we found the node, use the calculated offset; otherwise use text length - cursorPosition = found ? offset : text.length; - } - - // Find the trigger character before the cursor. - // A cursorPosition of 0 is a valid position (cursor at the very start), - // so it must not be rewritten to text.length. We still clamp to - // text.length because the DOM cursor offset may exceed the stripped text - // length (e.g. after removing a leading zero-width space). - const clampedCursorPosition = Math.min(cursorPosition, text.length); - - const trigger = resolveCompletionTrigger(text, clampedCursorPosition); - if (trigger && shouldAllowCompletionQuery(trigger.char, trigger.query)) { - // Get precise cursor position for menu - const cursorPos = getCursorPosition(); - if (cursorPos) { - await openCompletion(trigger.char, trigger.query, cursorPos); - return; - } - } - - // Close if no valid trigger - if (state.isOpen) { - closeCompletion(); - } - }; - - inputElement.addEventListener('input', handleInput); - return () => inputElement.removeEventListener('input', handleInput); - }, [inputRef, state.isOpen, openCompletion, closeCompletion]); - - return { - isOpen: state.isOpen, - triggerChar: state.triggerChar, - query: state.query, - position: state.position, - items: state.items, - closeCompletion, - openCompletion, - refreshCompletion, - }; -} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts deleted file mode 100644 index a564056a064..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useImage.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { build } from 'esbuild'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { MAX_IMAGE_SIZE } from '../../utils/imageSupport.js'; -import { formatFileSize } from './useImage.js'; - -describe('formatFileSize', () => { - it('formats small sizes with the right unit', () => { - expect(formatFileSize(0)).toBe('0 B'); - expect(formatFileSize(512)).toBe('512 B'); - expect(formatFileSize(1024)).toBe('1 KB'); - expect(formatFileSize(1536)).toBe('1.5 KB'); - expect(formatFileSize(1024 * 1024)).toBe('1 MB'); - }); - - it('describes the image size limit consistently with the constant', () => { - // The "too large" paste error interpolates this value, so it must read as - // a real size (and track MAX_IMAGE_SIZE) rather than a hardcoded string. - expect(formatFileSize(MAX_IMAGE_SIZE)).toBe('10 MB'); - }); - - it('handles terabyte-scale sizes without emitting "undefined"', () => { - // Regression: the previous ['B','KB','MB','GB'] list had no slot for - // index 4, so a >= 1 TB value rendered "… undefined". - expect(formatFileSize(2 * 1024 ** 4)).toBe('2 TB'); - }); - - it('clamps beyond the largest known unit instead of going out of bounds', () => { - expect(formatFileSize(1024 ** 5)).toBe('1024 TB'); - }); -}); - -describe('useImage browser bundle', () => { - it('bundles without resolving node-only qwen-code-core modules', async () => { - const entryPoint = fileURLToPath(new URL('./useImage.ts', import.meta.url)); - - await expect( - build({ - entryPoints: [entryPoint], - bundle: true, - format: 'esm', - logLevel: 'silent', - platform: 'browser', - write: false, - }), - ).resolves.toMatchObject({ - outputFiles: expect.any(Array), - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useImage.ts b/packages/vscode-ide-companion/src/webview/hooks/useImage.ts deleted file mode 100644 index 0079921e454..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useImage.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback, useRef, useState } from 'react'; -import type { ImageAttachment } from '../../utils/imageSupport.js'; -import { - MAX_IMAGE_SIZE, - MAX_TOTAL_IMAGE_SIZE, - isSupportedPastedImageMimeType, - getImageExtensionForMimeType, - splitMessageContentForImages, -} from '../../utils/imageSupport.js'; - -export type { ImageAttachment }; - -// ======================== Message Types ======================== - -export interface WebViewMessageBase { - role: 'user' | 'assistant' | 'thinking'; - content: string; - timestamp: number; - turnIndex?: number; - /** - * True for messages generated inside the webview itself (connection / - * auth / generic errors, the local "Interrupted" cancel mark) rather than - * delivered by the extension. The WebShell transcript renders ACP - * `transcriptUpdate` frames only, so the App renders these entries in a - * dedicated notice slot to keep them visible. - */ - localOnly?: boolean; - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }; -} - -export interface WebViewImageMessage extends WebViewMessageBase { - kind: 'image'; - imagePath: string; - imageSrc?: string; - imageMissing?: boolean; -} - -export type WebViewMessage = WebViewMessageBase | WebViewImageMessage; - -// ======================== Message Parsing ======================== - -export function expandUserMessageWithImages(message: WebViewMessageBase): { - messages: WebViewMessage[]; - imagePaths: string[]; -} { - const { text, imagePaths } = splitMessageContentForImages(message.content); - if (imagePaths.length === 0) { - return { messages: [message], imagePaths: [] }; - } - - const expanded: WebViewMessage[] = imagePaths.map((imagePath) => ({ - role: 'user', - content: '', - timestamp: message.timestamp, - turnIndex: message.turnIndex, - kind: 'image', - imagePath, - })); - - if (text) { - expanded.push({ - ...message, - content: text, - }); - } - - return { messages: expanded, imagePaths }; -} - -export function applyImageResolution( - messages: WebViewMessage[], - resolutions: Map, -): WebViewMessage[] { - if (messages.length === 0 || resolutions.size === 0) { - return messages; - } - - let changed = false; - const next = messages.map((message) => { - if (!('kind' in message) || message.kind !== 'image') { - return message; - } - - const resolved = resolutions.get(message.imagePath); - if (resolved === undefined) { - return message; - } - - const imageMissing = resolved === null; - const imageSrc = resolved ?? undefined; - if ( - message.imageSrc === imageSrc && - message.imageMissing === imageMissing - ) { - return message; - } - - changed = true; - return { - ...message, - imageSrc, - imageMissing, - }; - }); - - return changed ? next : messages; -} - -// ======================== useImagePaste ======================== - -async function fileToBase64(file: File | Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - -function isSupportedImage(file: File): boolean { - return isSupportedPastedImageMimeType(file.type); -} - -function isWithinSizeLimit(file: File): boolean { - return file.size <= MAX_IMAGE_SIZE; -} - -export function formatFileSize(bytes: number): string { - if (bytes === 0) { - return '0 B'; - } - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.max( - 0, - Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1), - ); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -async function createImageAttachment( - file: File, -): Promise { - if (!isSupportedImage(file)) { - return null; - } - - if (!isWithinSizeLimit(file)) { - return null; - } - - try { - const base64Data = await fileToBase64(file); - return { - id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - name: file.name || `image_${Date.now()}`, - type: file.type, - size: file.size, - data: base64Data, - timestamp: Date.now(), - }; - } catch { - return null; - } -} - -function generatePastedImageName(mimeType: string): string { - const now = new Date(); - const timeStr = `${now.getHours().toString().padStart(2, '0')}${now - .getMinutes() - .toString() - .padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`; - return `pasted_image_${timeStr}${getImageExtensionForMimeType(mimeType)}`; -} - -export function useImagePaste({ - onError, -}: { onError?: (error: string) => void } = {}) { - const [attachedImages, setAttachedImages] = useState([]); - const processingRef = useRef(false); - - const handleRemoveImage = useCallback((imageId: string) => { - setAttachedImages((prev) => prev.filter((img) => img.id !== imageId)); - }, []); - - const clearImages = useCallback(() => { - setAttachedImages([]); - }, []); - - const handlePaste = useCallback( - async (event: React.ClipboardEvent | ClipboardEvent) => { - if (processingRef.current) { - return; - } - - const clipboardData = event.clipboardData; - if (!clipboardData?.files?.length) { - return; - } - - processingRef.current = true; - event.preventDefault(); - event.stopPropagation(); - - const imageAttachments: ImageAttachment[] = []; - const errors: string[] = []; - let runningTotal = attachedImages.reduce((sum, img) => sum + img.size, 0); - - try { - for (let i = 0; i < clipboardData.files.length; i++) { - const file = clipboardData.files[i]; - - if (!file.type.startsWith('image/')) { - continue; - } - - if (!isSupportedImage(file)) { - errors.push(`Unsupported image type: ${file.type}`); - continue; - } - - if (!isWithinSizeLimit(file)) { - errors.push( - `Image "${file.name || 'pasted image'}" is too large (${formatFileSize(file.size)}). Maximum size is ${formatFileSize(MAX_IMAGE_SIZE)}.`, - ); - continue; - } - - if (runningTotal + file.size > MAX_TOTAL_IMAGE_SIZE) { - errors.push( - `Skipping image "${file.name || 'pasted image'}" – total attachment size would exceed ${formatFileSize(MAX_TOTAL_IMAGE_SIZE)}.`, - ); - continue; - } - - try { - // Clipboard pastes default to "image.png"; generate a timestamped name instead. - const imageFile = - file.name && file.name !== 'image.png' - ? file - : new File([file], generatePastedImageName(file.type), { - type: file.type, - }); - - const attachment = await createImageAttachment(imageFile); - if (attachment) { - imageAttachments.push(attachment); - runningTotal += attachment.size; - } - } catch { - errors.push( - `Failed to process image "${file.name || 'pasted image'}"`, - ); - } - } - - if (errors.length > 0) { - onError?.(errors.join('\n')); - } - - if (imageAttachments.length > 0) { - setAttachedImages((prev) => [...prev, ...imageAttachments]); - } - } finally { - processingRef.current = false; - } - }, - [attachedImages, onError], - ); - - return { attachedImages, handleRemoveImage, clearImages, handlePaste }; -} - -// ======================== useImageResolution ======================== - -export function useImageResolution({ - vscode, -}: { - vscode: { postMessage: (message: unknown) => void }; -}) { - const imageResolutionRef = useRef>(new Map()); - const pendingImagePathsRef = useRef>(new Set()); - const imageRequestIdRef = useRef(0); - - const expandMessages = useCallback( - ( - messages: WebViewMessageBase[], - ): { messages: WebViewMessage[]; imagePaths: string[] } => { - const expanded: WebViewMessage[] = []; - const allImagePaths: string[] = []; - - for (const message of messages) { - if (message.role === 'user') { - const result = expandUserMessageWithImages(message); - expanded.push(...result.messages); - allImagePaths.push(...result.imagePaths); - } else { - expanded.push(message); - } - } - - return { messages: expanded, imagePaths: allImagePaths }; - }, - [], - ); - - const applyCurrentImageResolutions = useCallback( - (messages: WebViewMessage[]): WebViewMessage[] => - applyImageResolution(messages, imageResolutionRef.current), - [], - ); - - const requestImageResolutions = useCallback( - (imagePaths: string[]) => { - if (imagePaths.length === 0) { - return; - } - - const pending = imagePaths.filter( - (p) => - !imageResolutionRef.current.has(p) && - !pendingImagePathsRef.current.has(p), - ); - - if (pending.length === 0) { - return; - } - - for (const p of pending) { - pendingImagePathsRef.current.add(p); - } - - imageRequestIdRef.current += 1; - vscode.postMessage({ - type: 'resolveImagePaths', - data: { paths: pending, requestId: imageRequestIdRef.current }, - }); - }, - [vscode], - ); - - const materializeMessages = useCallback( - (messages: WebViewMessageBase[]): WebViewMessage[] => { - const expanded = expandMessages(messages); - requestImageResolutions(expanded.imagePaths); - return applyCurrentImageResolutions(expanded.messages); - }, - [applyCurrentImageResolutions, expandMessages, requestImageResolutions], - ); - - const materializeMessage = useCallback( - (message: WebViewMessageBase): WebViewMessage[] => { - const expanded = - message.role === 'user' - ? expandUserMessageWithImages(message) - : { messages: [message], imagePaths: [] as string[] }; - requestImageResolutions(expanded.imagePaths); - return applyCurrentImageResolutions(expanded.messages); - }, - [applyCurrentImageResolutions, requestImageResolutions], - ); - - const mergeResolvedImages = useCallback( - ( - messages: WebViewMessage[], - resolved: Array<{ path: string; src?: string | null }>, - ): WebViewMessage[] => { - for (const item of resolved) { - pendingImagePathsRef.current.delete(item.path); - imageResolutionRef.current.set( - item.path, - item.src === null || item.src === undefined ? null : item.src, - ); - } - - return applyCurrentImageResolutions(messages); - }, - [applyCurrentImageResolutions], - ); - - const clearImageResolutions = useCallback(() => { - imageResolutionRef.current.clear(); - pendingImagePathsRef.current.clear(); - }, []); - - return { - materializeMessages, - materializeMessage, - mergeResolvedImages, - clearImageResolutions, - }; -} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.test.ts deleted file mode 100644 index d4a2b07a15f..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act, createElement, type FormEvent, type RefObject } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ZERO_WIDTH_SPACE, stripZeroWidthSpaces } from '@qwen-code/webui'; -import { shouldSendMessage, useMessageSubmit } from './useMessageSubmit.js'; - -type UseMessageSubmitProps = Parameters[0]; -type UseMessageSubmitApi = ReturnType; - -function createSubmitEvent(): FormEvent { - return { preventDefault: vi.fn() } as unknown as FormEvent; -} - -function createDefaultProps( - overrides: Partial = {}, -): UseMessageSubmitProps { - const inputField = document.createElement('div'); - const fileContext = { - getFileReference: vi.fn(), - activeFilePath: null, - activeFileName: null, - activeSelection: null, - clearFileReferences: vi.fn(), - ...overrides.fileContext, - }; - const messageHandling = { - setWaitingForResponse: vi.fn(), - ...overrides.messageHandling, - }; - - return { - vscode: { - postMessage: vi.fn(), - getState: vi.fn(), - setState: vi.fn(), - }, - inputText: 'hello', - setInputText: vi.fn(), - attachedImages: [], - clearImages: vi.fn(), - inputFieldRef: { - current: inputField, - } as RefObject, - isStreaming: false, - isWaitingForResponse: false, - fileContext, - messageHandling, - ...overrides, - }; -} - -function renderHookHarness(props: UseMessageSubmitProps) { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - let latestApi: UseMessageSubmitApi | null = null; - - function Harness() { - latestApi = useMessageSubmit(props); - return null; - } - - act(() => { - root.render(createElement(Harness)); - }); - - return { - container, - root, - get api(): UseMessageSubmitApi { - if (!latestApi) { - throw new Error('Hook API is not available'); - } - return latestApi; - }, - }; -} - -describe('ZERO_WIDTH_SPACE and stripZeroWidthSpaces', () => { - it('ZERO_WIDTH_SPACE is U+200B', () => { - expect(ZERO_WIDTH_SPACE).toBe('\u200B'); - expect(ZERO_WIDTH_SPACE.length).toBe(1); - }); - - it('strips a single leading zero-width space', () => { - expect(stripZeroWidthSpaces('\u200B')).toBe(''); - }); - - it('strips zero-width space before real text', () => { - expect(stripZeroWidthSpaces('\u200B/help')).toBe('/help'); - }); - - it('strips multiple zero-width spaces', () => { - expect(stripZeroWidthSpaces('\u200Bhello\u200B world\u200B')).toBe( - 'hello world', - ); - }); - - it('returns unchanged text when no zero-width spaces present', () => { - expect(stripZeroWidthSpaces('hello world')).toBe('hello world'); - }); - - it('returns empty string for empty input', () => { - expect(stripZeroWidthSpaces('')).toBe(''); - }); - - it('preserves other whitespace characters', () => { - expect(stripZeroWidthSpaces('\u200B \t\n')).toBe(' \t\n'); - }); -}); - -describe('shouldSendMessage', () => { - const defaults = { - isStreaming: false, - isWaitingForResponse: false, - }; - - it('returns false when streaming', () => { - expect( - shouldSendMessage({ ...defaults, inputText: 'hello', isStreaming: true }), - ).toBe(false); - }); - - it('returns false when waiting for response', () => { - expect( - shouldSendMessage({ - ...defaults, - inputText: 'hello', - isWaitingForResponse: true, - }), - ).toBe(false); - }); - - it('returns true for non-empty text', () => { - expect(shouldSendMessage({ ...defaults, inputText: 'hello' })).toBe(true); - }); - - it('returns false for empty text', () => { - expect(shouldSendMessage({ ...defaults, inputText: '' })).toBe(false); - }); - - it('returns false for whitespace-only text', () => { - expect(shouldSendMessage({ ...defaults, inputText: ' ' })).toBe(false); - }); - - it('returns false when input is only a zero-width space placeholder', () => { - expect(shouldSendMessage({ ...defaults, inputText: '\u200B' })).toBe(false); - }); - - it('returns false when input is zero-width space plus whitespace', () => { - expect(shouldSendMessage({ ...defaults, inputText: '\u200B ' })).toBe( - false, - ); - }); - - it('returns true when input has real text after zero-width space', () => { - expect(shouldSendMessage({ ...defaults, inputText: '\u200Bhello' })).toBe( - true, - ); - }); - - it('returns true when input has only attachments and no text', () => { - expect( - shouldSendMessage({ - ...defaults, - inputText: '', - attachedImages: [ - { - id: '1', - name: 'test.png', - type: 'image/png', - size: 100, - data: 'base64data', - timestamp: Date.now(), - }, - ], - }), - ).toBe(true); - }); - - it('returns true when input has only attachments and zero-width space', () => { - expect( - shouldSendMessage({ - ...defaults, - inputText: '\u200B', - attachedImages: [ - { - id: '1', - name: 'test.png', - type: 'image/png', - size: 100, - data: 'base64data', - timestamp: Date.now(), - }, - ], - }), - ).toBe(true); - }); -}); - -describe('useMessageSubmit', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('posts a normal sendMessage payload for non-edit submissions', () => { - const props = createDefaultProps({ inputText: 'normal prompt' }); - const rendered = renderHookHarness(props); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.handleSubmit(createSubmitEvent()); - }); - - expect(props.vscode.postMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { - text: 'normal prompt', - context: undefined, - fileContext: undefined, - attachments: undefined, - }, - }); - }); - - it('resolves raw image picker paths with spaces on submit', () => { - const imagePath = 'C:\\Users\\Me\\Pictures\\screen shot.png'; - const props = createDefaultProps({ - inputText: `describe @${imagePath} please`, - }); - props.fileContext.getFileReference = vi.fn((name: string) => - name === imagePath ? imagePath : undefined, - ); - const rendered = renderHookHarness(props); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.handleSubmit(createSubmitEvent()); - }); - - expect(props.vscode.postMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { - text: `describe @${imagePath} please`, - context: [ - { - type: 'file', - name: imagePath, - value: imagePath, - isImage: true, - }, - ], - fileContext: undefined, - attachments: undefined, - }, - }); - }); - - it('resolves multiple file references in one message', () => { - const imagePath = '/workspace/screen shot.png'; - const notePath = '/workspace/notes.md'; - const props = createDefaultProps({ - inputText: `compare @${imagePath} with @${notePath}`, - }); - props.fileContext.getFileReference = vi.fn((name: string) => { - if (name === imagePath) { - return imagePath; - } - if (name === notePath) { - return notePath; - } - return undefined; - }); - const rendered = renderHookHarness(props); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.handleSubmit(createSubmitEvent()); - }); - - expect(props.vscode.postMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { - text: `compare @${imagePath} with @${notePath}`, - context: [ - { - type: 'file', - name: imagePath, - value: imagePath, - isImage: true, - }, - { - type: 'file', - name: notePath, - value: notePath, - isImage: false, - }, - ], - fileContext: undefined, - attachments: undefined, - }, - }); - }); - - it('does not resolve file references from strict token prefixes', () => { - const props = createDefaultProps({ - inputText: 'open @data.csv.bak', - }); - props.fileContext.getFileReference = vi.fn((name: string) => - name === 'data.csv' ? '/workspace/data.csv' : undefined, - ); - const rendered = renderHookHarness(props); - root = rendered.root; - container = rendered.container; - - act(() => { - rendered.api.handleSubmit(createSubmitEvent()); - }); - - expect(props.vscode.postMessage).toHaveBeenCalledWith({ - type: 'sendMessage', - data: { - text: 'open @data.csv.bak', - context: undefined, - fileContext: undefined, - attachments: undefined, - }, - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts deleted file mode 100644 index af8f53db636..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback } from 'react'; -import type { VSCodeAPI } from './useVSCode.js'; -import { getRandomLoadingMessage } from '../../constants/loadingMessages.js'; -import type { ImageAttachment } from './useImage.js'; -import { ZERO_WIDTH_SPACE, stripZeroWidthSpaces } from '@qwen-code/webui'; -import { isDisplayableImagePath } from '../../utils/imageSupport.js'; - -interface UseMessageSubmitProps { - vscode: VSCodeAPI; - inputText: string; - setInputText: (text: string) => void; - attachedImages?: ImageAttachment[]; - clearImages?: () => void; - inputFieldRef: React.RefObject; - isStreaming: boolean; - isWaitingForResponse: boolean; - // When true, do NOT auto-attach the active editor file/selection to context - skipAutoActiveContext?: boolean; - - fileContext: { - getFileReference: (fileName: string) => string | undefined; - activeFilePath: string | null; - activeFileName: string | null; - activeSelection: { startLine: number; endLine: number } | null; - clearFileReferences: () => void; - }; - - messageHandling: { - setWaitingForResponse: (message: string) => void; - }; -} - -export const shouldSendMessage = ({ - inputText, - attachedImages, - isStreaming, - isWaitingForResponse, -}: { - inputText: string; - attachedImages?: ImageAttachment[]; - isStreaming: boolean; - isWaitingForResponse: boolean; -}): boolean => { - if (isStreaming || isWaitingForResponse) { - return false; - } - - const hasText = stripZeroWidthSpaces(inputText).trim().length > 0; - const hasAttachments = (attachedImages?.length ?? 0) > 0; - return hasText || hasAttachments; -}; - -function findFileReferences( - text: string, - getFileReference: (fileName: string) => string | undefined, -): Array<{ name: string; value: string }> { - const references: Array<{ name: string; value: string }> = []; - let currentIndex = 0; - - while (currentIndex < text.length) { - const atIndex = text.indexOf('@', currentIndex); - if (atIndex === -1) { - break; - } - - let matched = false; - // ponytail: O(n²) against short composer text; replace with map-key scan if composer parsing gets hot. - for (let end = text.length; end > atIndex + 1; end -= 1) { - const name = text.slice(atIndex + 1, end).trimEnd(); - const value = getFileReference(name); - if (value) { - const nextChar = end < text.length ? text[end] : ''; - if (nextChar !== '' && nextChar !== '@' && !/\s/.test(nextChar)) { - continue; - } - references.push({ name, value }); - currentIndex = end; - matched = true; - break; - } - } - - if (!matched) { - currentIndex = atIndex + 1; - } - } - - return references; -} - -/** - * Message submit Hook - * Handles message submission logic and context parsing - */ -export const useMessageSubmit = ({ - vscode, - inputText, - setInputText, - attachedImages = [], - clearImages, - inputFieldRef, - isStreaming, - isWaitingForResponse, - skipAutoActiveContext = false, - fileContext, - messageHandling, -}: UseMessageSubmitProps) => { - const handleSubmit = useCallback( - (e: React.FormEvent | React.KeyboardEvent, explicitText?: string) => { - e.preventDefault(); - - // Use explicit text if provided (e.g., from prompt suggestion Enter accept) - const textToSend = explicitText ?? inputText; - - if ( - !shouldSendMessage({ - inputText: textToSend, - attachedImages, - isStreaming, - isWaitingForResponse, - }) - ) { - return; - } - - // Handle /account command - show account info dialog - if (textToSend.trim() === '/account') { - setInputText(''); - if (inputFieldRef.current) { - inputFieldRef.current.textContent = ZERO_WIDTH_SPACE; - inputFieldRef.current.setAttribute('data-empty', 'true'); - } - vscode.postMessage({ type: 'getAccountInfo', data: {} }); - return; - } - - // Handle /auth (and its legacy alias /login) — trigger interactive - // auth flow directly in the extension instead of sending the command - // to the agent. - const trimmedInput = textToSend.trim(); - if (trimmedInput === '/auth' || trimmedInput === '/login') { - setInputText(''); - if (inputFieldRef.current) { - inputFieldRef.current.textContent = ZERO_WIDTH_SPACE; - inputFieldRef.current.setAttribute('data-empty', 'true'); - } - vscode.postMessage({ - type: 'auth', - data: {}, - }); - try { - messageHandling.setWaitingForResponse('Authenticating Qwen Code...'); - } catch (_err) { - // Best-effort UI hint; ignore if hook not available - } - return; - } - - messageHandling.setWaitingForResponse(getRandomLoadingMessage()); - - // Parse @file references from input text - const context: Array<{ - type: string; - name: string; - value: string; - startLine?: number; - endLine?: number; - isImage?: boolean; - }> = []; - for (const reference of findFileReferences( - textToSend, - fileContext.getFileReference, - )) { - context.push({ - type: 'file', - name: reference.name, - value: reference.value, - isImage: isDisplayableImagePath(reference.value), - }); - } - - // Add active file selection context if present and not skipped - if (fileContext.activeFilePath && !skipAutoActiveContext) { - const fileName = fileContext.activeFileName || 'current file'; - context.push({ - type: 'file', - name: fileName, - value: fileContext.activeFilePath, - startLine: fileContext.activeSelection?.startLine, - endLine: fileContext.activeSelection?.endLine, - }); - } - - let fileContextForMessage: - | { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - } - | undefined; - - if ( - fileContext.activeFilePath && - fileContext.activeFileName && - !skipAutoActiveContext - ) { - fileContextForMessage = { - fileName: fileContext.activeFileName, - filePath: fileContext.activeFilePath, - startLine: fileContext.activeSelection?.startLine, - endLine: fileContext.activeSelection?.endLine, - }; - } - - vscode.postMessage({ - type: 'sendMessage', - data: { - text: textToSend, - context: context.length > 0 ? context : undefined, - fileContext: fileContextForMessage, - attachments: attachedImages.length > 0 ? attachedImages : undefined, - }, - }); - - setInputText(''); - if (inputFieldRef.current) { - inputFieldRef.current.textContent = ZERO_WIDTH_SPACE; - inputFieldRef.current.setAttribute('data-empty', 'true'); - } - fileContext.clearFileReferences(); - if (clearImages) { - clearImages(); - } - }, - [ - inputText, - attachedImages, - clearImages, - isStreaming, - setInputText, - inputFieldRef, - vscode, - fileContext, - skipAutoActiveContext, - isWaitingForResponse, - messageHandling, - ], - ); - - return { handleSubmit }; -}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.test.tsx deleted file mode 100644 index 12a5bc890d2..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { useToolCalls } from './useToolCalls.js'; -import type { ToolCallUpdate } from '../../types/chatTypes.js'; - -type HookSnapshot = ReturnType; - -let latestSnapshot: HookSnapshot | null = null; - -function HookHarness() { - latestSnapshot = useToolCalls(); - return null; -} - -describe('useToolCalls', () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - act(() => { - root?.render(); - }); - }); - - afterEach(() => { - latestSnapshot = null; - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('stores structured rawOutput for agent tool calls across updates', () => { - const startUpdate = { - type: 'tool_call', - toolCallId: 'agent-1', - kind: 'other', - title: 'Launch agent', - status: 'in_progress', - rawOutput: { - type: 'task_execution', - subagentName: 'Explore', - taskDescription: 'Explore auth logic', - taskPrompt: 'Inspect auth flow implementation', - status: 'running', - }, - } as ToolCallUpdate & { rawOutput: unknown }; - - act(() => { - latestSnapshot?.handleToolCallUpdate(startUpdate); - }); - - expect(latestSnapshot?.toolCalls.get('agent-1')).toMatchObject({ - toolCallId: 'agent-1', - kind: 'other', - title: 'Launch agent', - status: 'in_progress', - rawOutput: { - type: 'task_execution', - taskDescription: 'Explore auth logic', - }, - }); - - const completionUpdate = { - type: 'tool_call_update', - toolCallId: 'agent-1', - status: 'completed', - rawOutput: { - type: 'task_execution', - subagentName: 'Explore', - taskDescription: 'Explore auth logic', - taskPrompt: 'Inspect auth flow implementation', - status: 'completed', - executionSummary: { - totalToolCalls: 3, - totalTokens: 1234, - totalDurationMs: 2200, - }, - }, - } as ToolCallUpdate & { rawOutput: unknown }; - - act(() => { - latestSnapshot?.handleToolCallUpdate(completionUpdate); - }); - - expect(latestSnapshot?.toolCalls.get('agent-1')).toMatchObject({ - status: 'completed', - rawOutput: { - type: 'task_execution', - status: 'completed', - executionSummary: { - totalToolCalls: 3, - totalTokens: 1234, - totalDurationMs: 2200, - }, - }, - }); - }); - - it('rewinds tool calls at and after a cutoff timestamp', () => { - act(() => { - latestSnapshot?.handleToolCallUpdate({ - type: 'tool_call', - toolCallId: 'before', - kind: 'other', - title: 'Before', - status: 'completed', - timestamp: 100, - }); - latestSnapshot?.handleToolCallUpdate({ - type: 'tool_call', - toolCallId: 'at-cutoff', - kind: 'other', - title: 'At cutoff', - status: 'completed', - timestamp: 200, - }); - latestSnapshot?.handleToolCallUpdate({ - type: 'tool_call', - toolCallId: 'after', - kind: 'other', - title: 'After', - status: 'completed', - timestamp: 300, - }); - }); - - act(() => { - latestSnapshot?.rewindToolCallsToTimestamp(200); - }); - - expect(Array.from(latestSnapshot?.toolCalls.keys() ?? [])).toEqual([ - 'before', - ]); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts deleted file mode 100644 index aa723ac1969..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback } from 'react'; -import type { ToolCallData } from '../components/messages/toolcalls/ToolCall.js'; -import type { ToolCallUpdate } from '../../types/chatTypes.js'; - -/** - * Tool call management Hook - * Manages tool call states and updates - */ -export const useToolCalls = () => { - const [toolCalls, setToolCalls] = useState>( - new Map(), - ); - - /** - * Preserve insertion order for existing tool calls by keeping the current - * timestamp. Only assign a new timestamp for brand-new entries. - */ - const resolveTimestamp = ( - update: ToolCallUpdate, - existing?: ToolCallData, - ): number => { - if ( - typeof existing?.timestamp === 'number' && - Number.isFinite(existing.timestamp) - ) { - return existing.timestamp; - } - if ( - typeof update.timestamp === 'number' && - Number.isFinite(update.timestamp) - ) { - return update.timestamp; - } - return Date.now(); - }; - - /** - * Handle tool call update - */ - const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { - setToolCalls((prevToolCalls) => { - const newMap = new Map(prevToolCalls); - const existing = newMap.get(update.toolCallId); - - // Helpers for todo/todos plan merging & content replacement - const isTodoWrite = (kind?: string) => - (kind || '').toLowerCase() === 'todo_write' || - (kind || '').toLowerCase() === 'todowrite' || - (kind || '').toLowerCase() === 'update_todos'; - - const normTitle = (t: unknown) => - typeof t === 'string' ? t.trim().toLowerCase() : ''; - - const isTodoTitleMergeable = (t?: unknown) => { - const nt = normTitle(t); - return nt === 'updated plan' || nt === 'update todos'; - }; - - const extractText = ( - content?: Array<{ - type: 'content' | 'diff'; - content?: { text?: string }; - }>, - ): string => { - if (!content || content.length === 0) { - return ''; - } - const parts: string[] = []; - for (const item of content) { - if (item.type === 'content' && item.content?.text) { - parts.push(String(item.content.text)); - } - } - return parts.join('\n'); - }; - - const normalizeTodoLines = (text: string): string[] => { - if (!text) { - return []; - } - const lines = text - .split(/\r?\n/) - .map((l) => l.trim()) - .filter(Boolean); - return lines.map((line) => { - const idx = line.indexOf('] '); - return idx >= 0 ? line.slice(idx + 2).trim() : line; - }); - }; - - const isSameOrSupplement = ( - prevText: string, - nextText: string, - ): { same: boolean; supplement: boolean } => { - const prev = normalizeTodoLines(prevText); - const next = normalizeTodoLines(nextText); - if (prev.length === next.length) { - const same = prev.every((l, i) => l === next[i]); - if (same) { - return { same: true, supplement: false }; - } - } - // supplement = prev set is subset of next set - const setNext = new Set(next); - const subset = prev.every((l) => setNext.has(l)); - return { same: false, supplement: subset }; - }; - - const safeTitle = (title: unknown): string => { - if (typeof title === 'string') { - return title; - } - if (title && typeof title === 'object') { - return JSON.stringify(title); - } - return 'Tool Call'; - }; - - if (update.type === 'tool_call') { - const content = update.content?.map((item) => ({ - type: item.type as 'content' | 'diff', - content: item.content, - path: item.path, - oldText: item.oldText, - newText: item.newText, - })); - - // Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos), - // if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new. - if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) { - const nextText = extractText(content); - // Find the most recent card with todo_write + mergeable title - let lastId: string | null = null; - let lastText = ''; - let lastTimestamp = 0; - for (const tc of newMap.values()) { - if ( - isTodoWrite(tc.kind) && - isTodoTitleMergeable(tc.title) && - typeof tc.timestamp === 'number' && - tc.timestamp >= lastTimestamp - ) { - lastId = tc.toolCallId; - lastText = extractText(tc.content); - lastTimestamp = tc.timestamp || 0; - } - } - - if (lastId) { - const cmp = isSameOrSupplement(lastText, nextText); - if (cmp.same) { - // Completely identical: Ignore this addition - return newMap; - } - if (cmp.supplement) { - // Supplement: Replace content to the previous item (using update semantics) - const prev = newMap.get(lastId); - if (prev) { - newMap.set(lastId, { - ...prev, - content, // Override (do not append) - status: update.status || prev.status, - timestamp: resolveTimestamp(update, prev), - }); - return newMap; - } - } - } - } - - newMap.set(update.toolCallId, { - toolCallId: update.toolCallId, - kind: update.kind || 'other', - title: safeTitle(update.title), - status: update.status || 'pending', - rawInput: update.rawInput as string | object | undefined, - rawOutput: update.rawOutput, - content, - locations: update.locations, - timestamp: resolveTimestamp(update), - }); - } else if (update.type === 'tool_call_update') { - const updatedContent = update.content - ? update.content.map((item) => ({ - type: item.type as 'content' | 'diff', - content: item.content, - path: item.path, - oldText: item.oldText, - newText: item.newText, - })) - : undefined; - - if (existing) { - // Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates - let mergedContent = existing.content; - if (updatedContent) { - if ( - isTodoWrite(update.kind || existing.kind) && - (isTodoTitleMergeable(update.title) || - isTodoTitleMergeable(existing.title)) - ) { - mergedContent = updatedContent; // Override - } else { - mergedContent = [...(existing.content || []), ...updatedContent]; - } - } - const nextTimestamp = resolveTimestamp(update, existing); - - newMap.set(update.toolCallId, { - ...existing, - ...(update.kind && { kind: update.kind }), - ...(update.title && { title: safeTitle(update.title) }), - ...(update.status && { status: update.status }), - ...(update.rawOutput !== undefined && { - rawOutput: update.rawOutput, - }), - content: mergedContent, - ...(update.locations && { locations: update.locations }), - timestamp: nextTimestamp, - }); - } else { - newMap.set(update.toolCallId, { - toolCallId: update.toolCallId, - kind: update.kind || 'other', - title: update.title ? safeTitle(update.title) : '', - status: update.status || 'pending', - rawInput: update.rawInput as string | object | undefined, - rawOutput: update.rawOutput, - content: updatedContent, - locations: update.locations, - timestamp: resolveTimestamp(update), - }); - } - } - - return newMap; - }); - }, []); - - /** - * Clear all tool calls - */ - const clearToolCalls = useCallback(() => { - setToolCalls(new Map()); - }, []); - - const rewindToolCallsToTimestamp = useCallback((cutoffTimestamp: number) => { - setToolCalls((prevToolCalls) => { - const next = new Map(); - for (const [id, toolCall] of prevToolCalls) { - if ((toolCall.timestamp ?? 0) < cutoffTimestamp) { - next.set(id, toolCall); - } - } - return next; - }); - }, []); - - /** - * Get in-progress tool calls - */ - const inProgressToolCalls = Array.from(toolCalls.values()).filter( - (toolCall) => - toolCall.status === 'pending' || toolCall.status === 'in_progress', - ); - - /** - * Get completed tool calls - */ - const completedToolCalls = Array.from(toolCalls.values()).filter( - (toolCall) => - toolCall.status === 'completed' || toolCall.status === 'failed', - ); - - return { - toolCalls, - inProgressToolCalls, - completedToolCalls, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - }; -}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts deleted file mode 100644 index ecd70ea87fd..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi } from 'vitest'; -import { - liftToolNameFromMeta, - resetConversationState, -} from './useWebViewMessages.js'; - -describe('liftToolNameFromMeta', () => { - it('lifts _meta.toolName onto toolCall.toolName', () => { - const toolCall = { _meta: { toolName: 'agent' } } as Parameters< - typeof liftToolNameFromMeta - >[0]; - liftToolNameFromMeta(toolCall); - expect(toolCall?.toolName).toBe('agent'); - }); - - it('does not overwrite a pre-existing toolName', () => { - const toolCall = { - toolName: 'already-set', - _meta: { toolName: 'agent' }, - } as Parameters[0]; - liftToolNameFromMeta(toolCall); - expect(toolCall?.toolName).toBe('already-set'); - }); - - it('is a no-op when _meta is absent', () => { - const toolCall = { kind: 'other' } as Parameters< - typeof liftToolNameFromMeta - >[0]; - liftToolNameFromMeta(toolCall); - expect(toolCall?.toolName).toBeUndefined(); - }); - - it('does not crash on an undefined toolCall', () => { - expect(() => liftToolNameFromMeta(undefined)).not.toThrow(); - }); -}); - -describe('resetConversationState', () => { - it('clears retained usage stats when a conversation is reset', () => { - const clearMessages = vi.fn(); - const endStreaming = vi.fn(); - const clearWaitingForResponse = vi.fn(); - const clearThinking = vi.fn(); - const clearToolCalls = vi.fn(); - const clearActiveExecToolCalls = vi.fn(); - const setPlanEntries = vi.fn(); - const handlePermissionRequest = vi.fn(); - const handleAskUserQuestion = vi.fn(); - const setCurrentSessionId = vi.fn(); - const setCurrentSessionTitle = vi.fn(); - const setUsageStats = vi.fn(); - const clearImageResolutions = vi.fn(); - const postMessage = vi.fn(); - - resetConversationState({ - handlers: { - messageHandling: { - clearMessages, - endStreaming, - clearWaitingForResponse, - clearThinking, - }, - clearToolCalls, - clearActiveExecToolCalls, - setPlanEntries, - handlePermissionRequest, - handleAskUserQuestion, - sessionManagement: { - setCurrentSessionId, - setCurrentSessionTitle, - }, - setUsageStats, - }, - clearImageResolutions, - vscode: { - postMessage, - }, - }); - - expect(endStreaming).toHaveBeenCalled(); - expect(clearWaitingForResponse).toHaveBeenCalled(); - expect(clearThinking).toHaveBeenCalled(); - expect(clearMessages).toHaveBeenCalled(); - expect(clearToolCalls).toHaveBeenCalled(); - expect(clearActiveExecToolCalls).toHaveBeenCalled(); - expect(setPlanEntries).toHaveBeenCalledWith([]); - expect(handlePermissionRequest).toHaveBeenCalledWith(null); - expect(handleAskUserQuestion).toHaveBeenCalledWith(null); - expect(setCurrentSessionId).toHaveBeenCalledWith(null); - expect(clearImageResolutions).toHaveBeenCalled(); - expect(setUsageStats).toHaveBeenCalledWith(undefined); - expect(setCurrentSessionTitle).toHaveBeenCalledWith('Past Conversations'); - expect(postMessage).toHaveBeenCalledWith({ - type: 'updatePanelTitle', - data: { title: 'Qwen Code' }, - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx deleted file mode 100644 index 2bbec361839..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx +++ /dev/null @@ -1,770 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { act, createRef } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useWebViewMessages } from './useWebViewMessages.js'; - -const { mockPostMessage, mockClearImageResolutions } = vi.hoisted(() => ({ - mockPostMessage: vi.fn(), - mockClearImageResolutions: vi.fn(), -})); - -vi.mock('./useVSCode.js', () => ({ - useVSCode: () => ({ - postMessage: mockPostMessage, - }), -})); - -vi.mock('./useImage.js', () => ({ - useImageResolution: () => ({ - materializeMessages: (messages: T) => messages, - materializeMessage: (message: T) => [message], - mergeResolvedImages: (messages: T) => messages, - clearImageResolutions: mockClearImageResolutions, - }), -})); - -function renderHookHarness(overrides?: { - setUsageStats?: ReturnType; - endStreaming?: ReturnType; - clearWaitingForResponse?: ReturnType; - setInsightReportPath?: ReturnType; - setInsightProgress?: ReturnType; -}) { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - const setUsageStats = overrides?.setUsageStats ?? vi.fn(); - const endStreaming = overrides?.endStreaming ?? vi.fn(); - const clearWaitingForResponse = overrides?.clearWaitingForResponse ?? vi.fn(); - const setInsightReportPath = overrides?.setInsightReportPath ?? vi.fn(); - const setInsightProgress = overrides?.setInsightProgress ?? vi.fn(); - - const handlers = { - sessionManagement: { - currentSessionId: 'conversation-1', - setQwenSessions: vi.fn(), - setCurrentSessionId: vi.fn(), - setCurrentSessionTitle: vi.fn(), - setShowSessionSelector: vi.fn(), - setNextCursor: vi.fn(), - setHasMore: vi.fn(), - setIsLoading: vi.fn(), - setIsSwitchingSession: vi.fn(), - }, - fileContext: { - setActiveFileName: vi.fn(), - setActiveFilePath: vi.fn(), - setActiveSelection: vi.fn(), - setWorkspaceFilesFromResponse: vi.fn(), - addFileReference: vi.fn(), - }, - messageHandling: { - messages: [ - { role: 'user', content: 'first', timestamp: 100 }, - { role: 'assistant', content: 'first reply', timestamp: 200 }, - { role: 'user', content: 'second', timestamp: 300 }, - { role: 'assistant', content: 'second reply', timestamp: 400 }, - ], - setMessages: vi.fn(), - addMessage: vi.fn(), - clearMessages: vi.fn(), - startStreaming: vi.fn(), - appendStreamChunk: vi.fn(), - endStreaming, - breakAssistantSegment: vi.fn(), - breakThinkingSegment: vi.fn(), - appendThinkingChunk: vi.fn(), - clearThinking: vi.fn(), - setWaitingForResponse: vi.fn(), - clearWaitingForResponse, - }, - handleToolCallUpdate: vi.fn(), - clearToolCalls: vi.fn(), - rewindToolCallsToTimestamp: vi.fn(), - setPlanEntries: vi.fn(), - handlePermissionRequest: vi.fn(), - handleAskUserQuestion: vi.fn(), - inputFieldRef: createRef(), - setInputText: vi.fn(), - setEditMode: vi.fn(), - setIsAuthenticated: vi.fn(), - setUsageStats, - setModelInfo: vi.fn(), - setAvailableCommands: vi.fn(), - setAvailableModels: vi.fn(), - setInsightReportPath, - setInsightProgress, - }; - - function Harness() { - useWebViewMessages(handlers); - return null; - } - - act(() => { - root.render(); - }); - - return { - container, - root, - handlers, - setUsageStats, - endStreaming, - clearWaitingForResponse, - setInsightReportPath, - setInsightProgress, - }; -} - -describe('useWebViewMessages', () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - beforeEach(() => { - vi.clearAllMocks(); - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - root = null; - } - if (container) { - container.remove(); - container = null; - } - }); - - it('fully resets local UI state when a conversation is cleared', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationCleared', - data: {}, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.clearMessages).toHaveBeenCalled(); - expect(rendered.handlers.clearToolCalls).toHaveBeenCalled(); - expect( - rendered.handlers.sessionManagement.setCurrentSessionId, - ).toHaveBeenCalledWith(null); - expect(rendered.endStreaming).toHaveBeenCalled(); - expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); - expect(mockClearImageResolutions).toHaveBeenCalled(); - expect(rendered.setUsageStats).toHaveBeenCalledWith(undefined); - expect(rendered.handlers.setPlanEntries).toHaveBeenCalledWith([]); - expect(rendered.handlers.handlePermissionRequest).toHaveBeenCalledWith( - null, - ); - expect(rendered.handlers.handleAskUserQuestion).toHaveBeenCalledWith(null); - expect( - rendered.handlers.sessionManagement.setCurrentSessionTitle, - ).toHaveBeenCalledWith('Past Conversations'); - expect(mockPostMessage).toHaveBeenCalledWith({ - type: 'updatePanelTitle', - data: { title: 'Qwen Code' }, - }); - }); - - it('clears stale execute-tool tracking before the next session ends', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'toolCall', - data: { - toolCallId: 'exec-1', - kind: 'execute', - status: 'in_progress', - rawInput: 'ls', - }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationCleared', - data: {}, - }, - }), - ); - }); - - rendered.clearWaitingForResponse.mockClear(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'streamEnd', - data: {}, - }, - }), - ); - }); - - expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); - }); - - it('ignores background streamEnd while a tagged request is active', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'streamStart', - data: { requestId: 'req-1', timestamp: 123 }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'streamEnd', - data: { - reason: 'end_turn', - source: 'background_notification', - }, - }, - }), - ); - }); - - expect(rendered.endStreaming).not.toHaveBeenCalled(); - expect( - rendered.handlers.messageHandling.clearThinking, - ).not.toHaveBeenCalled(); - }); - - it('drops transcript state from the edited user turn onward', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationRewound', - data: { targetTurnIndex: 1 }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.setMessages).toHaveBeenCalledWith([ - { role: 'user', content: 'first', timestamp: 100 }, - { role: 'assistant', content: 'first reply', timestamp: 200 }, - ]); - expect(rendered.handlers.rewindToolCallsToTimestamp).toHaveBeenCalledWith( - 300, - ); - expect(rendered.handlers.setPlanEntries).toHaveBeenCalledWith([]); - expect(rendered.setUsageStats).toHaveBeenCalledWith(undefined); - }); - - it('ignores conversation rewind events when the target turn is missing', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationRewound', - data: { targetTurnIndex: 99 }, - }, - }), - ); - }); - - expect( - rendered.handlers.messageHandling.setMessages, - ).not.toHaveBeenCalled(); - expect(rendered.handlers.rewindToolCallsToTimestamp).not.toHaveBeenCalled(); - expect(rendered.handlers.setPlanEntries).not.toHaveBeenCalled(); - expect(rendered.clearWaitingForResponse).not.toHaveBeenCalled(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'message', - data: { role: 'user', content: 'next', timestamp: 500 }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.addMessage).toHaveBeenCalledWith({ - role: 'user', - content: 'next', - timestamp: 500, - turnIndex: 0, - }); - }); - - it('indexes user turns after switching to a persisted session', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'qwenSessionSwitched', - data: { - sessionId: 'conversation-2', - session: { title: 'Persisted Session' }, - messages: [ - { role: 'user', content: 'persisted first', timestamp: 10 }, - { role: 'assistant', content: 'reply', timestamp: 20 }, - { role: 'user', content: 'persisted second', timestamp: 30 }, - ], - }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.setMessages).toHaveBeenCalledWith([ - { - role: 'user', - content: 'persisted first', - timestamp: 10, - turnIndex: 0, - }, - { role: 'assistant', content: 'reply', timestamp: 20 }, - { - role: 'user', - content: 'persisted second', - timestamp: 30, - turnIndex: 1, - }, - ]); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'message', - data: { role: 'user', content: 'next', timestamp: 40 }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.addMessage).toHaveBeenCalledWith({ - role: 'user', - content: 'next', - timestamp: 40, - turnIndex: 2, - }); - }); - - it('indexes user turns when loading a conversation transcript', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationLoaded', - data: { - messages: [ - { role: 'user', content: 'loaded first', timestamp: 10 }, - { role: 'assistant', content: 'reply', timestamp: 20 }, - { role: 'user', content: 'loaded second', timestamp: 30 }, - ], - }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.setMessages).toHaveBeenCalledWith([ - { - role: 'user', - content: 'loaded first', - timestamp: 10, - turnIndex: 0, - }, - { role: 'assistant', content: 'reply', timestamp: 20 }, - { - role: 'user', - content: 'loaded second', - timestamp: 30, - turnIndex: 1, - }, - ]); - }); - - it('resets user turn indexing after a conversation is cleared', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'qwenSessionSwitched', - data: { - sessionId: 'conversation-2', - session: { title: 'Persisted Session' }, - messages: [ - { role: 'user', content: 'persisted first', timestamp: 10 }, - { role: 'assistant', content: 'reply', timestamp: 20 }, - { role: 'user', content: 'persisted second', timestamp: 30 }, - ], - }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'conversationCleared', - data: {}, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'message', - data: { role: 'user', content: 'restart', timestamp: 40 }, - }, - }), - ); - }); - - expect( - rendered.handlers.messageHandling.addMessage, - ).toHaveBeenLastCalledWith({ - role: 'user', - content: 'restart', - timestamp: 40, - turnIndex: 0, - }); - }); - - it('resets user turn indexing when switching to a session without messages', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'qwenSessionSwitched', - data: { - sessionId: 'conversation-2', - session: { title: 'Empty Session' }, - }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'message', - data: { role: 'user', content: 'first', timestamp: 10 }, - }, - }), - ); - }); - - expect(rendered.handlers.messageHandling.clearMessages).toHaveBeenCalled(); - expect(rendered.handlers.messageHandling.addMessage).toHaveBeenCalledWith({ - role: 'user', - content: 'first', - timestamp: 10, - turnIndex: 0, - }); - }); - - it('clears the generic waiting state when insight progress starts', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'insightProgress', - data: { - stage: 'Analyzing sessions', - progress: 42, - detail: '21/50', - }, - }, - }), - ); - }); - - expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); - }); - - it('clears waiting state when authCancelled is received', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'authCancelled', - }, - }), - ); - }); - - expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); - }); - - it('stores the latest insight report path when the ready event arrives', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'insightReportReady', - data: { - path: '/tmp/insight-report.html', - }, - }, - }), - ); - }); - - expect(rendered.setInsightReportPath).toHaveBeenCalledWith( - '/tmp/insight-report.html', - ); - }); - - it('inserts resolved image attachments as raw absolute references', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - const input = document.createElement('div'); - ( - rendered.handlers.inputFieldRef as { current: HTMLDivElement | null } - ).current = input; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'fileAttached', - data: { - id: 'file-1', - type: 'file', - name: 'screen shot.png', - value: 'C:\\Users\\Me\\Pictures\\screen shot.png', - }, - }, - }), - ); - }); - - expect(rendered.handlers.fileContext.addFileReference).toHaveBeenCalledWith( - 'screen shot.png', - 'C:\\Users\\Me\\Pictures\\screen shot.png', - ); - expect(rendered.handlers.fileContext.addFileReference).toHaveBeenCalledWith( - 'C:\\Users\\Me\\Pictures\\screen shot.png', - 'C:\\Users\\Me\\Pictures\\screen shot.png', - ); - expect(input.textContent).toBe( - '@C:\\Users\\Me\\Pictures\\screen shot.png ', - ); - expect(rendered.handlers.setInputText).toHaveBeenCalledWith( - '@C:\\Users\\Me\\Pictures\\screen shot.png ', - ); - }); - - it('keeps non-image attachments as file-name references', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - const input = document.createElement('div'); - ( - rendered.handlers.inputFieldRef as { current: HTMLDivElement | null } - ).current = input; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'fileAttached', - data: { - id: 'file-1', - type: 'file', - name: 'notes.txt', - value: 'C:\\Users\\Me\\Documents\\notes.txt', - }, - }, - }), - ); - }); - - expect(input.textContent).toBe('@notes.txt '); - expect(rendered.handlers.setInputText).toHaveBeenCalledWith('@notes.txt '); - }); - - it('marks locally generated error notices so the App can render them outside the ACP transcript', () => { - const rendered = renderHookHarness(); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'agentConnectionError', - data: { message: 'spawn failed' }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'authError', - data: { message: 'bad token' }, - }, - }), - ); - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'error', - data: { message: 'boom' }, - }, - }), - ); - }); - - const added = rendered.handlers.messageHandling.addMessage.mock.calls.map( - (call) => - call[0] as { role?: string; content?: string; localOnly?: boolean }, - ); - expect(added).toHaveLength(3); - for (const message of added) { - expect(message.localOnly).toBe(true); - expect(message.role).toBe('assistant'); - } - expect(added[0]?.content).toContain('Failed to connect to Qwen agent'); - expect(added[1]?.content).toBe('bad token'); - expect(added[2]?.content).toBe('boom'); - }); - - it('delivers insight progress and report path to the provided setters', () => { - const setInsightReportPath = vi.fn(); - const setInsightProgress = vi.fn(); - const rendered = renderHookHarness({ - setInsightReportPath, - setInsightProgress, - }); - root = rendered.root; - container = rendered.container; - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'insightProgress', - data: { stage: 'Analyzing', progress: 0.4 }, - }, - }), - ); - }); - - expect(rendered.setInsightProgress).toHaveBeenCalledWith({ - stage: 'Analyzing', - progress: 0.4, - detail: undefined, - }); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'insightReportReady', - data: { path: '/tmp/insight-report.md' }, - }, - }), - ); - }); - - expect(rendered.setInsightReportPath).toHaveBeenCalledWith( - '/tmp/insight-report.md', - ); - // Report ready clears the progress indicator. - expect(rendered.setInsightProgress).toHaveBeenLastCalledWith(null); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts deleted file mode 100644 index 4563c588358..00000000000 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ /dev/null @@ -1,1417 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useEffect, useRef, useCallback } from 'react'; -import { useVSCode } from './useVSCode.js'; -import type { Conversation } from '../../services/conversationStore.js'; -import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; -import type { - ToolCallUpdate, - UsageStatsPayload, -} from '../../types/chatTypes.js'; -import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; -import type { PlanEntry } from '../../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; -import type { Question } from '../../types/acpTypes.js'; -import { - useImageResolution, - type WebViewMessage, - type WebViewMessageBase, -} from './useImage.js'; -import { isDisplayableImagePath } from '../../utils/imageSupport.js'; - -const FORCE_CLEAR_STREAM_END_REASONS = new Set([ - 'user_cancelled', - 'cancelled', - 'timeout', - 'error', - 'session_expired', -]); - -interface UseWebViewMessagesProps { - // Session management - sessionManagement: { - currentSessionId: string | null; - setQwenSessions: ( - sessions: - | Array> - | (( - prev: Array>, - ) => Array>), - ) => void; - setCurrentSessionId: (id: string | null) => void; - setCurrentSessionTitle: (title: string) => void; - setShowSessionSelector: (show: boolean) => void; - setNextCursor: (cursor: number | undefined) => void; - setHasMore: (hasMore: boolean) => void; - setIsLoading: (loading: boolean) => void; - setIsSwitchingSession: (switching: boolean) => void; - }; - - // File context - fileContext: { - setActiveFileName: (name: string | null) => void; - setActiveFilePath: (path: string | null) => void; - setActiveSelection: ( - selection: { startLine: number; endLine: number } | null, - ) => void; - setWorkspaceFilesFromResponse: ( - files: Array<{ - id: string; - label: string; - description: string; - path: string; - }>, - requestId?: number, - ) => void; - addFileReference: (name: string, path: string) => void; - }; - - // Message handling - messageHandling: { - messages: WebViewMessage[]; - setMessages: ( - messages: - | WebViewMessage[] - | ((prev: WebViewMessage[]) => WebViewMessage[]), - ) => void; - addMessage: (message: WebViewMessage) => void; - clearMessages: () => void; - startStreaming: (timestamp?: number) => void; - appendStreamChunk: (chunk: string) => void; - endStreaming: () => void; - breakAssistantSegment: () => void; - breakThinkingSegment: () => void; - appendThinkingChunk: (chunk: string) => void; - clearThinking: () => void; - setWaitingForResponse: (message: string) => void; - clearWaitingForResponse: () => void; - }; - - // Tool calls - handleToolCallUpdate: (update: ToolCallUpdate) => void; - clearToolCalls: () => void; - rewindToolCallsToTimestamp?: (cutoffTimestamp: number) => void; - - // Plan - setPlanEntries: (entries: PlanEntry[]) => void; - - // Permission - // When request is non-null, open/update the permission drawer. - // When null, close the drawer (used when extension simulates a choice). - handlePermissionRequest: ( - request: { - options: PermissionOption[]; - toolCall: PermissionToolCall; - } | null, - ) => void; - - // Ask User Question - handleAskUserQuestion: ( - request: { - questions: Question[]; - sessionId: string; - metadata?: { - source?: string; - }; - } | null, - ) => void; - - // Input - inputFieldRef: React.RefObject; - setInputText: (text: string) => void; - // Edit mode setter (maps ACP modes to UI modes) - setEditMode?: (mode: ApprovalModeValue) => void; - // Authentication state setter - setIsAuthenticated?: (authenticated: boolean | null) => void; - // Usage stats setter - setUsageStats?: (stats: UsageStatsPayload | undefined) => void; - // Model info setter - setModelInfo?: (info: ModelInfo | null) => void; - // Available commands setter - setAvailableCommands?: (commands: AvailableCommand[]) => void; - // Available skills setter - setAvailableSkills?: (skills: string[]) => void; - // Available models setter - setAvailableModels?: (models: ModelInfo[]) => void; - // Account info setter (triggers dialog) - setAccountInfo?: ( - info: { - authType?: string | null; - baseUrl?: string | null; - envKey?: string | null; - modelId?: string | null; - error?: string; - } | null, - ) => void; - // Latest generated insight report path - setInsightReportPath?: (path: string | null) => void; - // Latest structured insight progress update - setInsightProgress?: ( - progress: { stage: string; progress: number; detail?: string } | null, - ) => void; -} - -type ConversationResetHandlers = { - messageHandling: Pick< - UseWebViewMessagesProps['messageHandling'], - | 'clearMessages' - | 'endStreaming' - | 'clearWaitingForResponse' - | 'clearThinking' - >; - clearToolCalls: UseWebViewMessagesProps['clearToolCalls']; - clearActiveExecToolCalls: () => void; - setPlanEntries: UseWebViewMessagesProps['setPlanEntries']; - handlePermissionRequest: UseWebViewMessagesProps['handlePermissionRequest']; - handleAskUserQuestion: UseWebViewMessagesProps['handleAskUserQuestion']; - sessionManagement: Pick< - UseWebViewMessagesProps['sessionManagement'], - 'setCurrentSessionId' | 'setCurrentSessionTitle' - >; - resetUserTurnCounter?: () => void; - setUsageStats?: UseWebViewMessagesProps['setUsageStats']; -}; - -/** - * Surface the canonical tool name (the ACP frame's `_meta.toolName`) onto the - * PermissionToolCall so the drawer can render tool-specific UI (e.g. the Agent - * tool's "Launch this agent?" prompt) without depending on a protocol `kind` - * ACP can't carry. Mutates in place; a pre-existing `toolName` is preserved and - * an absent `_meta` is a no-op. - */ -export function liftToolNameFromMeta( - toolCall: - | (PermissionToolCall & { _meta?: { toolName?: string } }) - | undefined, -): void { - if ( - toolCall && - toolCall.toolName === undefined && - typeof toolCall._meta?.toolName === 'string' - ) { - toolCall.toolName = toolCall._meta.toolName; - } -} - -export function resetConversationState({ - handlers, - clearImageResolutions, - vscode, -}: { - handlers: ConversationResetHandlers; - clearImageResolutions: () => void; - vscode: { postMessage: (message: unknown) => void }; -}) { - handlers.messageHandling.endStreaming(); - handlers.clearActiveExecToolCalls(); - handlers.messageHandling.clearWaitingForResponse(); - handlers.messageHandling.clearThinking(); - handlers.messageHandling.clearMessages(); - handlers.clearToolCalls(); - handlers.resetUserTurnCounter?.(); - handlers.setPlanEntries([]); - handlers.handlePermissionRequest(null); - handlers.handleAskUserQuestion(null); - handlers.sessionManagement.setCurrentSessionId(null); - clearImageResolutions(); - handlers.setUsageStats?.(undefined); - handlers.sessionManagement.setCurrentSessionTitle('Past Conversations'); - // Reset the VS Code tab title to default label - vscode.postMessage({ - type: 'updatePanelTitle', - data: { title: 'Qwen Code' }, - }); -} - -function indexUserMessagesForEditRewind(messages: WebViewMessageBase[]): { - messages: WebViewMessageBase[]; - nextTurnIndex: number; -} { - let nextTurnIndex = 0; - const indexedMessages = messages.map((entry) => { - if (entry.role !== 'user') { - return entry; - } - const indexed = { ...entry, turnIndex: nextTurnIndex }; - nextTurnIndex += 1; - return indexed; - }); - - return { messages: indexedMessages, nextTurnIndex }; -} - -function restoreMessagesForEditRewind({ - messages, - materializeMessages, -}: { - messages: WebViewMessageBase[]; - materializeMessages: (messages: WebViewMessageBase[]) => WebViewMessage[]; -}): { messages: WebViewMessage[]; nextTurnIndex: number } { - // IMPORTANT: indexUserMessagesForEditRewind must be called before - // materializeMessages. Image message expansion creates additional - // user-role entries that share the parent's turnIndex. - const { messages: indexedMessages, nextTurnIndex } = - indexUserMessagesForEditRewind(messages); - - return { - messages: materializeMessages(indexedMessages), - nextTurnIndex, - }; -} - -/** - * WebView message handling Hook - * Handles all messages from VSCode Extension uniformly - */ -export const useWebViewMessages = ({ - sessionManagement, - fileContext, - messageHandling, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - setPlanEntries, - handlePermissionRequest, - handleAskUserQuestion, - inputFieldRef, - setInputText, - setEditMode, - setIsAuthenticated, - setUsageStats, - setModelInfo, - setAvailableCommands, - setAvailableSkills, - setAvailableModels, - setAccountInfo, - setInsightReportPath, - setInsightProgress, -}: UseWebViewMessagesProps) => { - // VS Code API for posting messages back to the extension host - const vscode = useVSCode(); - - // Image resolution handling - const { - materializeMessages, - materializeMessage, - mergeResolvedImages, - clearImageResolutions, - } = useImageResolution({ - vscode, - }); - - // Track active long-running tool calls (execute/bash/command) so we can - // keep the bottom "waiting" message visible until all of them complete. - const activeExecToolCallsRef = useRef>(new Set()); - const activeInsightRunRef = useRef(false); - const modelInfoRef = useRef(null); - // Track the active requestId from the latest streamStart so we can - // discard stale streamEnd events from cancelled/previous requests. - const activeRequestIdRef = useRef(null); - const userTurnCounterRef = useRef(0); - // Use ref to store callbacks to avoid useEffect dependency issues - const handlersRef = useRef({ - sessionManagement, - fileContext, - messageHandling, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - setPlanEntries, - handlePermissionRequest, - handleAskUserQuestion, - setIsAuthenticated, - setUsageStats, - setModelInfo, - setAvailableCommands, - setAvailableSkills, - setAvailableModels, - setAccountInfo, - setInsightReportPath, - setInsightProgress, - }); - - // Track last "Updated Plan" snapshot toolcall to support merge/dedupe - const lastPlanSnapshotRef = useRef<{ - id: string; - text: string; // joined lines - lines: string[]; - } | null>(null); - - const buildPlanLines = (entries: PlanEntry[]): string[] => - entries.map((e) => { - const mark = - e.status === 'completed' ? 'x' : e.status === 'in_progress' ? '-' : ' '; - return `- [${mark}] ${e.content}`.trim(); - }); - - const isSupplementOf = ( - prevLines: string[], - nextLines: string[], - ): boolean => { - // Consider "supplement" = old content text collection (ignoring status) is contained in new content - const key = (line: string) => { - const idx = line.indexOf('] '); - return idx >= 0 ? line.slice(idx + 2).trim() : line.trim(); - }; - const nextSet = new Set(nextLines.map(key)); - for (const pl of prevLines) { - if (!nextSet.has(key(pl))) { - return false; - } - } - return true; - }; - - const clearInsightState = () => { - activeInsightRunRef.current = false; - handlersRef.current.setInsightProgress?.(null); - handlersRef.current.setInsightReportPath?.(null); - }; - - const setInsightProgressState = (progress: { - stage: string; - progress: number; - detail?: string; - }) => { - activeInsightRunRef.current = true; - handlersRef.current.setInsightReportPath?.(null); - handlersRef.current.messageHandling.clearWaitingForResponse(); - handlersRef.current.setInsightProgress?.(progress); - }; - - const setInsightReportReadyState = (path: string | null) => { - activeInsightRunRef.current = false; - handlersRef.current.setInsightProgress?.(null); - handlersRef.current.setInsightReportPath?.(path); - }; - - // Update refs - useEffect(() => { - handlersRef.current = { - sessionManagement, - fileContext, - messageHandling, - handleToolCallUpdate, - clearToolCalls, - rewindToolCallsToTimestamp, - setPlanEntries, - handlePermissionRequest, - handleAskUserQuestion, - setIsAuthenticated, - setUsageStats, - setModelInfo, - setAvailableCommands, - setAvailableSkills, - setAvailableModels, - setAccountInfo, - setInsightReportPath, - setInsightProgress, - }; - }); - - const handleMessage = useCallback( - (event: MessageEvent) => { - const message = event.data; - const handlers = handlersRef.current; - - switch (message.type) { - case 'modeInfo': { - // Initialize UI mode from ACP initialize - try { - const current = (message.data?.currentModeId || - 'default') as ApprovalModeValue; - setEditMode?.(current); - } catch (_error) { - // best effort - } - break; - } - - case 'modeChanged': { - try { - const modeId = (message.data?.modeId || - 'default') as ApprovalModeValue; - setEditMode?.(modeId); - } catch (_error) { - // Ignore error when setting mode - } - break; - } - - case 'modelChanged': { - try { - const model = message.data?.model as ModelInfo | undefined; - if (model) { - handlers.setModelInfo?.(model); - } - } catch (_error) { - // Ignore error when setting model - } - break; - } - - case 'availableCommands': { - try { - const commands = message.data?.commands as - | AvailableCommand[] - | undefined; - if (commands) { - handlers.setAvailableCommands?.(commands); - } - } catch (_error) { - // Ignore error when setting available commands - } - break; - } - - case 'availableSkills': { - try { - const skills = message.data?.skills as string[] | undefined; - if (skills) { - handlers.setAvailableSkills?.(skills); - } - } catch (_error) { - // Ignore error when setting available skills - } - break; - } - - case 'availableModels': { - try { - const models = message.data?.models as ModelInfo[] | undefined; - console.log( - '[useWebViewMessages] availableModels message received:', - models, - ); - if (models) { - handlers.setAvailableModels?.(models); - console.log( - '[useWebViewMessages] setAvailableModels called with:', - models, - ); - } - } catch (_error) { - // Ignore error when setting available models - console.error( - '[useWebViewMessages] Error setting available models:', - _error, - ); - } - break; - } - - case 'usageStats': { - const stats = message.data as UsageStatsPayload | undefined; - handlers.setUsageStats?.(stats); - break; - } - - case 'modelInfo': { - const info = message.data as Partial | undefined; - if ( - info && - typeof info.name === 'string' && - info.name.trim().length > 0 - ) { - const modelId = - typeof info.modelId === 'string' && info.modelId.trim().length > 0 - ? info.modelId.trim() - : info.name.trim(); - const normalized: ModelInfo = { - modelId, - name: info.name.trim(), - ...(typeof info.description !== 'undefined' - ? { description: info.description ?? null } - : {}), - ...(typeof info._meta !== 'undefined' - ? { _meta: info._meta } - : {}), - }; - modelInfoRef.current = normalized; - handlers.setModelInfo?.(normalized); - } else { - modelInfoRef.current = null; - handlers.setModelInfo?.(null); - } - break; - } - - case 'authSuccess': { - handlers.messageHandling.clearWaitingForResponse(); - handlers.setIsAuthenticated?.(true); - break; - } - - case 'agentConnected': { - // Agent connected successfully; clear any pending spinner - handlers.messageHandling.clearWaitingForResponse(); - // Set authentication state to true - handlers.setIsAuthenticated?.(true); - break; - } - - case 'agentConnectionError': { - // Agent connection failed; surface the error and unblock the UI - handlers.messageHandling.clearWaitingForResponse(); - const errorMsg = - (message?.data?.message as string) || - 'Failed to connect to Qwen agent.'; - - handlers.messageHandling.addMessage({ - role: 'assistant', - content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, - timestamp: Date.now(), - localOnly: true, - }); - // Set authentication state to false - handlers.setIsAuthenticated?.(false); - break; - } - - case 'authError': { - // Clear loading state and show error notice - handlers.messageHandling.clearWaitingForResponse(); - const errorMsg = - (message?.data?.message as string) || - 'Auth failed. Please try again.'; - handlers.messageHandling.addMessage({ - role: 'assistant', - content: errorMsg, - timestamp: Date.now(), - localOnly: true, - }); - // Set authentication state to false - handlers.setIsAuthenticated?.(false); - break; - } - - case 'authCancelled': { - // User dismissed the auth picker — clear loading state so the - // input is not left disabled. - handlers.messageHandling.clearWaitingForResponse(); - break; - } - - case 'authState': { - const state = ( - message?.data as { authenticated?: boolean | null } | undefined - )?.authenticated; - if (typeof state === 'boolean') { - handlers.setIsAuthenticated?.(state); - } else { - handlers.setIsAuthenticated?.(null); - } - break; - } - - case 'accountInfo': { - const info = message?.data as - | { - authType?: string | null; - baseUrl?: string | null; - envKey?: string | null; - modelId?: string | null; - error?: string; - } - | undefined; - handlers.setAccountInfo?.(info ?? null); - break; - } - - case 'conversationLoaded': { - const conversation = message.data as Conversation; - const { messages: restoredMessages, nextTurnIndex } = - restoreMessagesForEditRewind({ - messages: conversation.messages as WebViewMessageBase[], - materializeMessages, - }); - userTurnCounterRef.current = nextTurnIndex; - clearInsightState(); - clearImageResolutions(); - handlers.messageHandling.setMessages(restoredMessages); - break; - } - - case 'message': { - const msg = message.data as { - role?: 'user' | 'assistant' | 'thinking'; - content?: string; - timestamp?: number; - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }; - }; - const baseMessage = msg as WebViewMessageBase; - if (baseMessage.role === 'user') { - baseMessage.turnIndex = userTurnCounterRef.current; - userTurnCounterRef.current += 1; - } - materializeMessage(baseMessage).forEach((entry) => - handlers.messageHandling.addMessage(entry), - ); - // Robustness: if an assistant message arrives outside the normal stream - // pipeline (no explicit streamEnd), ensure we clear streaming/waiting states - if (msg.role === 'assistant') { - try { - handlers.messageHandling.endStreaming(); - } catch (_error) { - // no-op: stream might not have been started - console.warn('[PanelManager] Failed to end streaming:', _error); - } - // Important: Do NOT blindly clear the waiting message if there are - // still active tool calls running. We keep the waiting indicator - // tied to tool-call lifecycle instead. - if (activeExecToolCallsRef.current.size === 0) { - try { - handlers.messageHandling.clearWaitingForResponse(); - } catch (_error) { - // no-op: already cleared - console.warn( - '[PanelManager] Failed to clear waiting for response:', - _error, - ); - } - } - } - break; - } - - case 'conversationRewound': { - const targetTurnIndex = - typeof message.data?.targetTurnIndex === 'number' - ? message.data.targetTurnIndex - : -1; - if (targetTurnIndex < 0) { - break; - } - - const currentMessages = handlers.messageHandling.messages; - let fallbackUserTurnIndex = 0; - let truncateAt = currentMessages.length; - let cutoffTimestamp = Date.now(); - let foundTargetTurn = false; - - for (let i = 0; i < currentMessages.length; i++) { - const msg = currentMessages[i]; - if (msg?.role !== 'user') { - continue; - } - const turnIndex = - typeof msg.turnIndex === 'number' - ? msg.turnIndex - : fallbackUserTurnIndex; - fallbackUserTurnIndex = Math.max( - fallbackUserTurnIndex, - turnIndex + 1, - ); - if (turnIndex === targetTurnIndex) { - truncateAt = i; - cutoffTimestamp = msg.timestamp; - foundTargetTurn = true; - break; - } - } - - if (!foundTargetTurn) { - console.warn( - '[useWebViewMessages] conversationRewound target turn not found:', - targetTurnIndex, - ); - break; - } - - userTurnCounterRef.current = targetTurnIndex; - handlers.messageHandling.setMessages( - currentMessages.slice(0, truncateAt), - ); - handlers.rewindToolCallsToTimestamp?.(cutoffTimestamp); - activeExecToolCallsRef.current.clear(); - clearInsightState(); - clearImageResolutions(); - handlers.setPlanEntries([]); - lastPlanSnapshotRef.current = null; - handlers.setUsageStats?.(undefined); - handlers.handlePermissionRequest(null); - handlers.handleAskUserQuestion(null); - handlers.messageHandling.clearWaitingForResponse(); - handlers.messageHandling.clearThinking(); - break; - } - - case 'streamStart': { - const startData = message.data as - | { timestamp?: number; requestId?: string } - | undefined; - // Store the requestId so we can validate streamEnd events - activeRequestIdRef.current = startData?.requestId ?? null; - handlers.messageHandling.startStreaming(startData?.timestamp); - break; - } - - case 'streamChunk': { - handlers.messageHandling.appendStreamChunk(message.data.chunk); - break; - } - - case 'thoughtChunk': { - const chunk = message.data.content || message.data.chunk || ''; - handlers.messageHandling.appendThinkingChunk(chunk); - break; - } - - case 'streamEnd': { - const endData = message.data as - | { reason?: string; requestId?: string; source?: string } - | undefined; - const endRequestId = endData?.requestId ?? null; - - // Drop stale or untagged streamEnd when a tagged stream is active. - if (activeRequestIdRef.current) { - if (endRequestId !== activeRequestIdRef.current) { - console.log( - '[useWebViewMessages] Ignoring stale/untagged streamEnd:', - endRequestId, - 'active:', - activeRequestIdRef.current, - 'source:', - endData?.source, - ); - break; - } - } - - // Always end local streaming state and clear thinking state - handlers.messageHandling.endStreaming(); - handlers.messageHandling.clearThinking(); - activeRequestIdRef.current = null; - - // If stream ended due to explicit user cancellation, proactively clear - // waiting indicator and reset tracked execution calls. - // This avoids UI getting stuck with Stop button visible after - // rejecting a permission request. - try { - const reason = (endData?.reason || '').toLowerCase(); - - /** - * Handle different types of stream end reasons that require a full reset: - * - 'user_cancelled' / 'cancelled': user explicitly cancelled - * - 'timeout' / 'error' / 'session_expired': request failed unexpectedly - * For these cases, immediately clear all active states. - */ - if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) { - // Clear active execution tool call tracking, reset state - activeExecToolCallsRef.current.clear(); - if (activeInsightRunRef.current) { - clearInsightState(); - } - // Clear waiting response state to ensure UI returns to normal - handlers.messageHandling.clearWaitingForResponse(); - break; - } - } catch (_error) { - // Best-effort handling, errors don't affect main flow - } - - /** - * For other types of stream end (non-user cancellation): - * Only clear generic waiting indicator when there are no active - * long-running tool calls. If there are still active execute/bash/command - * calls, keep the hint visible. - */ - if (activeExecToolCallsRef.current.size === 0) { - handlers.messageHandling.clearWaitingForResponse(); - } - break; - } - - case 'error': { - handlers.messageHandling.endStreaming(); - handlers.messageHandling.clearThinking(); - activeExecToolCallsRef.current.clear(); - if (activeInsightRunRef.current) { - clearInsightState(); - } - handlers.messageHandling.clearWaitingForResponse(); - handlers.sessionManagement.setIsSwitchingSession(false); - // Display error message to user so they know what went wrong - const errorMessage = - (message?.data?.message as string) || - 'An unexpected error occurred.'; - handlers.messageHandling.addMessage({ - role: 'assistant', - content: errorMessage, - timestamp: Date.now(), - localOnly: true, - }); - break; - } - - case 'permissionRequest': { - // Surface the canonical tool name (the ACP frame's `_meta.toolName`) - // onto the PermissionToolCall so the drawer can render tool-specific - // UI (e.g. the Agent tool's "Launch this agent?" prompt) without - // depending on a protocol `kind` ACP can't carry. - liftToolNameFromMeta( - message.data?.toolCall as - | (PermissionToolCall & { _meta?: { toolName?: string } }) - | undefined, - ); - - handlers.handlePermissionRequest(message.data); - - const permToolCall = message.data?.toolCall as { - toolCallId?: string; - kind?: string; - title?: string; - status?: string; - content?: unknown[]; - locations?: Array<{ path: string; line?: number | null }>; - }; - - if (permToolCall?.toolCallId) { - // Infer kind more robustly for permission preview: - // - If content contains a diff entry, force 'edit' so the EditToolCall can handle it properly - // - Else try title-based hints; fall back to provided kind or 'execute' - let kind = permToolCall.kind || 'execute'; - const contentArr = (permToolCall.content as unknown[]) || []; - const hasDiff = Array.isArray(contentArr) - ? contentArr.some( - (c: unknown) => - !!c && - typeof c === 'object' && - (c as { type?: string }).type === 'diff', - ) - : false; - if (hasDiff) { - kind = 'edit'; - - // Auto-open diff view for edit operations with diff content - // This replaces the useEffect auto-trigger in EditToolCall component - const diffContent = contentArr.find( - (c: unknown) => - !!c && - typeof c === 'object' && - (c as { type?: string }).type === 'diff', - ) as - | { path?: string; oldText?: string; newText?: string } - | undefined; - - if ( - diffContent?.path && - diffContent?.oldText !== undefined && - diffContent?.newText !== undefined - ) { - vscode.postMessage({ - type: 'openDiff', - data: { - path: diffContent.path, - oldText: diffContent.oldText, - newText: diffContent.newText, - }, - }); - } - } else if (permToolCall.title) { - const title = permToolCall.title.toLowerCase(); - if (title.includes('touch') || title.includes('echo')) { - kind = 'execute'; - } else if (title.includes('read') || title.includes('cat')) { - kind = 'read'; - } else if (title.includes('write') || title.includes('edit')) { - kind = 'edit'; - } - } - - const normalizedStatus = ( - permToolCall.status === 'pending' || - permToolCall.status === 'in_progress' || - permToolCall.status === 'completed' || - permToolCall.status === 'failed' - ? permToolCall.status - : 'pending' - ) as ToolCallUpdate['status']; - - handlers.handleToolCallUpdate({ - type: 'tool_call', - toolCallId: permToolCall.toolCallId, - kind, - title: permToolCall.title, - status: normalizedStatus, - content: permToolCall.content as ToolCallUpdate['content'], - locations: permToolCall.locations, - }); - - // Split assistant stream so subsequent chunks start a new assistant message - handlers.messageHandling.breakAssistantSegment(); - handlers.messageHandling.breakThinkingSegment(); - } - break; - } - - case 'permissionResolved': { - // Extension proactively resolved a pending permission; close drawer. - try { - handlers.handlePermissionRequest(null); - } catch (_error) { - console.warn( - '[useWebViewMessages] failed to close permission UI:', - _error, - ); - } - break; - } - - case 'askUserQuestion': { - // Handle ask user question request from extension - const questionsData = message.data as { - questions: Question[]; - sessionId: string; - metadata?: { - source?: string; - }; - }; - handlers.handleAskUserQuestion(questionsData); - break; - } - - case 'plan': - if (message.data.entries && Array.isArray(message.data.entries)) { - const entries = message.data.entries as PlanEntry[]; - handlers.setPlanEntries(entries); - - // Generate new snapshot text - const lines = buildPlanLines(entries); - const text = lines.join('\n'); - const prev = lastPlanSnapshotRef.current; - - // 1) Identical -> Skip - if (prev && prev.text === text) { - break; - } - - try { - const ts = Date.now(); - - // 2) Supplement or status update -> Merge to previous (use tool_call_update to override content) - if (prev && isSupplementOf(prev.lines, lines)) { - handlers.handleToolCallUpdate({ - type: 'tool_call_update', - toolCallId: prev.id, - kind: 'todo_write', - title: 'Updated Plan', - status: 'completed', - content: [ - { - type: 'content', - content: { type: 'text', text }, - }, - ], - timestamp: ts, - }); - lastPlanSnapshotRef.current = { id: prev.id, text, lines }; - } else { - // 3) Other cases -> Add a new history card - const toolCallId = `plan-snapshot-${ts}`; - handlers.handleToolCallUpdate({ - type: 'tool_call', - toolCallId, - kind: 'todo_write', - title: 'Updated Plan', - status: 'completed', - content: [ - { - type: 'content', - content: { type: 'text', text }, - }, - ], - timestamp: ts, - }); - lastPlanSnapshotRef.current = { id: toolCallId, text, lines }; - } - - // Split assistant message segments, keep rendering blocks independent - handlers.messageHandling.breakAssistantSegment?.(); - handlers.messageHandling.breakThinkingSegment?.(); - } catch (_error) { - console.warn( - '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', - _error, - ); - } - } - break; - - case 'toolCall': - case 'toolCallUpdate': { - const toolCallData = message.data; - if (toolCallData.sessionUpdate && !toolCallData.type) { - toolCallData.type = toolCallData.sessionUpdate; - } - handlers.handleToolCallUpdate(toolCallData); - - // Split assistant stream - const status = (toolCallData.status || '').toString(); - const isStart = toolCallData.type === 'tool_call'; - const isFinalUpdate = - toolCallData.type === 'tool_call_update' && - (status === 'completed' || status === 'failed'); - if (isStart || isFinalUpdate) { - handlers.messageHandling.breakAssistantSegment(); - handlers.messageHandling.breakThinkingSegment(); - } - - // While long-running tools (e.g., execute/bash/command) are in progress, - // surface a lightweight loading indicator and expose the Stop button. - try { - const id = (toolCallData.toolCallId || '').toString(); - const kind = (toolCallData.kind || '').toString().toLowerCase(); - const isExecKind = - kind === 'execute' || kind === 'bash' || kind === 'command'; - // CLI sometimes omits kind in tool_call_update payloads; fall back to - // whether we've already tracked this ID as an exec tool. - const wasTrackedExec = activeExecToolCallsRef.current.has(id); - const isExec = isExecKind || wasTrackedExec; - - if (!isExec || !id) { - break; - } - - if (status === 'pending' || status === 'in_progress') { - if (isExecKind) { - activeExecToolCallsRef.current.add(id); - - // Build a helpful hint from rawInput - const rawInput = toolCallData.rawInput; - let cmd = ''; - if (typeof rawInput === 'string') { - cmd = rawInput; - } else if (rawInput && typeof rawInput === 'object') { - const maybe = rawInput as { command?: string }; - cmd = maybe.command || ''; - } - const hint = cmd ? `Running: ${cmd}` : 'Running command...'; - handlers.messageHandling.setWaitingForResponse(hint); - } - } else if (status === 'completed' || status === 'failed') { - activeExecToolCallsRef.current.delete(id); - } - - // If no active exec tool remains, clear the waiting message. - if (activeExecToolCallsRef.current.size === 0) { - handlers.messageHandling.clearWaitingForResponse(); - } - } catch (_error) { - // Best-effort UI hint; ignore errors - } - break; - } - - case 'qwenSessionList': { - const sessions = - (message.data.sessions as Array>) || []; - const append = Boolean(message.data.append); - const nextCursor = message.data.nextCursor as number | undefined; - const hasMore = Boolean(message.data.hasMore); - - handlers.sessionManagement.setQwenSessions( - (prev: Array>) => - append ? [...prev, ...sessions] : sessions, - ); - handlers.sessionManagement.setNextCursor(nextCursor); - handlers.sessionManagement.setHasMore(hasMore); - handlers.sessionManagement.setIsLoading(false); - if ( - handlers.sessionManagement.currentSessionId && - sessions.length > 0 - ) { - const currentSession = sessions.find( - (s: Record) => - (s.id as string) === - handlers.sessionManagement.currentSessionId || - (s.sessionId as string) === - handlers.sessionManagement.currentSessionId, - ); - if (currentSession) { - const title = - (currentSession.title as string) || - (currentSession.name as string) || - 'Past Conversations'; - handlers.sessionManagement.setCurrentSessionTitle(title); - } - } - break; - } - - case 'qwenSessionSwitched': - handlers.sessionManagement.setShowSessionSelector(false); - clearInsightState(); - if (message.data.sessionId) { - handlers.sessionManagement.setCurrentSessionId( - message.data.sessionId as string, - ); - } - if (message.data.session) { - const session = message.data.session as Record; - const title = - (session.title as string) || - (session.name as string) || - 'Past Conversations'; - handlers.sessionManagement.setCurrentSessionTitle(title); - // Update the VS Code webview tab title as well - vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); - } - if (message.data.messages) { - clearImageResolutions(); - const { messages: restoredMessages, nextTurnIndex } = - restoreMessagesForEditRewind({ - messages: message.data.messages as WebViewMessageBase[], - materializeMessages, - }); - userTurnCounterRef.current = nextTurnIndex; - handlers.messageHandling.setMessages(restoredMessages); - } else { - userTurnCounterRef.current = 0; - handlers.messageHandling.clearMessages(); - } - - // Clear any waiting message that might be displayed from previous session - handlers.messageHandling.clearWaitingForResponse(); - - // Clear active tool calls tracking - activeExecToolCallsRef.current.clear(); - - // Clear and restore tool calls if provided in session data - handlers.clearToolCalls(); - if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) { - message.data.toolCalls.forEach((toolCall: unknown) => { - if (toolCall && typeof toolCall === 'object') { - handlers.handleToolCallUpdate(toolCall as ToolCallUpdate); - } - }); - } - - // Restore plan entries if provided - if ( - message.data.planEntries && - Array.isArray(message.data.planEntries) - ) { - handlers.setPlanEntries(message.data.planEntries); - } else { - handlers.setPlanEntries([]); - } - lastPlanSnapshotRef.current = null; - break; - - case 'sessionLoadComplete': - case 'sessionExpired': - handlers.sessionManagement.setIsSwitchingSession(false); - break; - - case 'conversationCleared': - clearInsightState(); - resetConversationState({ - handlers: { - ...handlers, - clearActiveExecToolCalls: () => { - activeExecToolCallsRef.current.clear(); - }, - resetUserTurnCounter: () => { - userTurnCounterRef.current = 0; - }, - }, - clearImageResolutions, - vscode, - }); - lastPlanSnapshotRef.current = null; - break; - - case 'sessionTitleUpdated': { - const sessionId = message.data?.sessionId as string; - const title = message.data?.title as string; - if (sessionId && title) { - handlers.sessionManagement.setCurrentSessionId(sessionId); - handlers.sessionManagement.setCurrentSessionTitle(title); - // Ask extension host to reflect this title in the tab label - vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); - } - break; - } - - case 'sessionDeleted': { - const deletedId = message.data?.sessionId as string; - if (deletedId) { - handlers.sessionManagement.setQwenSessions( - (prev: Array>) => - prev.filter( - (s) => s.sessionId !== deletedId && s.id !== deletedId, - ), - ); - } - break; - } - - case 'sessionRenamed': { - const renamedId = message.data?.sessionId as string; - const newTitle = message.data?.title as string; - if (renamedId && newTitle) { - handlers.sessionManagement.setQwenSessions( - (prev: Array>) => - prev.map((s) => - s.sessionId === renamedId || s.id === renamedId - ? { ...s, title: newTitle, name: newTitle } - : s, - ), - ); - } - break; - } - - case 'activeEditorChanged': { - const fileName = message.data?.fileName as string | null; - const filePath = message.data?.filePath as string | null; - const selection = message.data?.selection as { - startLine: number; - endLine: number; - } | null; - handlers.fileContext.setActiveFileName(fileName); - handlers.fileContext.setActiveFilePath(filePath); - handlers.fileContext.setActiveSelection(selection); - break; - } - - case 'fileAttached': { - const attachment = message.data as { - id: string; - type: string; - name: string; - value: string; - }; - - handlers.fileContext.addFileReference( - attachment.name, - attachment.value, - ); - - if (inputFieldRef.current) { - const currentText = inputFieldRef.current.textContent || ''; - const referenceText = isDisplayableImagePath(attachment.value) - ? attachment.value - : attachment.name; - if (referenceText !== attachment.name) { - handlers.fileContext.addFileReference( - referenceText, - attachment.value, - ); - } - const newText = currentText - ? `${currentText} @${referenceText} ` - : `@${referenceText} `; - inputFieldRef.current.textContent = newText; - setInputText(newText); - - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(inputFieldRef.current); - range.collapse(false); - sel?.removeAllRanges(); - sel?.addRange(range); - } - break; - } - - case 'workspaceFiles': { - const files = message.data?.files as Array<{ - id: string; - label: string; - description: string; - path: string; - }>; - const requestId = message.data?.requestId as number | undefined; - if (files) { - console.log('[WebView] Received workspaceFiles:', files.length); - handlers.fileContext.setWorkspaceFilesFromResponse( - files, - requestId, - ); - } - break; - } - - case 'imagePathsResolved': { - const resolved = - ( - message.data as - | { resolved?: Array<{ path: string; src?: string | null }> } - | undefined - )?.resolved ?? []; - handlers.messageHandling.setMessages((prevMessages) => - mergeResolvedImages(prevMessages, resolved), - ); - break; - } - - case 'insightProgress': { - const stage = message.data?.stage as string | undefined; - const progress = message.data?.progress as number | undefined; - const detail = message.data?.detail as string | undefined; - if (typeof stage === 'string' && typeof progress === 'number') { - setInsightProgressState({ - stage, - progress, - detail, - }); - } - break; - } - - case 'insightProgressCleared': { - clearInsightState(); - break; - } - - case 'insightReportReady': { - const path = message.data?.path as string | undefined; - setInsightReportReadyState(path ?? null); - break; - } - case 'cancelStreaming': - // Handle cancel streaming response from extension - // Note: The "Interrupted" message is already added by handleCancel in App.tsx - // to provide immediate UI feedback. We only need to ensure streaming states - // are properly cleaned up here. - if (activeInsightRunRef.current) { - clearInsightState(); - } - handlers.messageHandling.endStreaming(); - handlers.messageHandling.clearWaitingForResponse(); - break; - - default: - break; - } - }, - [ - inputFieldRef, - setInputText, - vscode, - setEditMode, - materializeMessages, - materializeMessage, - mergeResolvedImages, - clearImageResolutions, - ], - ); - - useEffect(() => { - window.addEventListener('message', handleMessage); - // Notify extension that the webview is ready to receive initialization state. - vscode.postMessage({ type: 'webviewReady', data: {} }); - return () => window.removeEventListener('message', handleMessage); - }, [handleMessage, vscode]); -}; diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx index 43b47260723..308a43350da 100644 --- a/packages/vscode-ide-companion/src/webview/index.tsx +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -5,27 +5,13 @@ */ import ReactDOM from 'react-dom/client'; -import { App } from './App.js'; -import { VSCodePlatformProvider } from './context/VSCodePlatformProvider.js'; +import { EmbeddedApp } from './EmbeddedApp.js'; import { initializeWebviewLogger } from './hooks/useVSCode.js'; -// Import webui shared styles (CSS variables, component styles) -import '@qwen-code/webui/styles.css'; - -// VSCode-specific: Tailwind utilities + theme variables -// eslint-disable-next-line import/no-internal-modules -import './styles/tailwind.css'; -// eslint-disable-next-line import/no-internal-modules -import './styles/App.css'; - initializeWebviewLogger(); const container = document.getElementById('root'); if (container) { const root = ReactDOM.createRoot(container); - root.render( - - - , - ); + root.render(); } diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts index d8d3e4fb715..06ff58bd05e 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { WebViewContent } from './WebViewContent.js'; +const envMock = vi.hoisted(() => ({ language: 'en' })); + vi.mock('vscode', () => ({ + env: envMock, Uri: { joinPath: vi.fn((_base: unknown, ...parts: string[]) => ({ fsPath: `/ext/${parts.join('/')}`, @@ -20,9 +23,15 @@ vi.mock('vscode', () => ({ */ function createMockWebview() { return { - asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ - toString: () => `https://webview/${uri.fsPath}`, - })), + asWebviewUri: vi.fn((uri: { fsPath: string }) => { + const toString = () => `https://webview/${uri.fsPath}`; + return { + toString, + with: ({ query }: { query?: string } = {}) => ({ + toString: () => (query ? `${toString()}?${query}` : toString()), + }), + }; + }), cspSource: 'https://csp.source', }; } @@ -30,6 +39,10 @@ function createMockWebview() { describe('WebViewContent', () => { const fakeExtensionUri = { fsPath: '/ext' } as never; + beforeEach(() => { + envMock.language = 'en'; + }); + it('generates HTML when given a raw Webview', () => { const webview = createMockWebview(); const html = WebViewContent.generate(webview as never, fakeExtensionUri); @@ -89,10 +102,38 @@ describe('WebViewContent', () => { expect(html).toContain('font-src data:;'); }); + it('fills the VS Code webview without inherited body padding', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain('html, body, #root {'); + expect(html).toContain('height: 100%;'); + expect(html).toContain('margin: 0;'); + expect(html).toContain('padding: 0;'); + expect(html).toContain('box-sizing: border-box;'); + expect(html).toContain('#root {\n display: flex;'); + }); + it('does not set data-web-shell-transcript on the body', () => { const webview = createMockWebview(); const html = WebViewContent.generate(webview as never, fakeExtensionUri); expect(html).not.toContain('data-web-shell-transcript'); }); + + it('injects the VS Code locale into the html lang attribute', () => { + envMock.language = 'zh-cn'; + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(''); + }); + + it('falls back to en when the VS Code locale is empty', () => { + envMock.language = ''; + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(''); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts index 0422e44f218..daf813fefc4 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts @@ -17,7 +17,7 @@ type WebviewHost = vscode.Webview | { webview: vscode.Webview }; export class WebViewContent { /** * Extract the underlying Webview from various host types. - * Accepts a raw Webview, a WebviewPanel, or a WebviewView — so callers + * Accepts a raw Webview, a WebviewPanel, or WebviewView — so callers * never have to worry about passing the wrong wrapper. */ private static getWebview(host: WebviewHost): vscode.Webview { @@ -34,9 +34,9 @@ export class WebViewContent { */ static generate(host: WebviewHost, extensionUri: vscode.Uri): string { const webview = this.getWebview(host); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'), - ); + const scriptUri = webview + .asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js')) + .with({ query: `v=${Date.now()}` }); // Convert extension URI for webview access - this allows frontend to construct resource paths const extensionUriForWebview = webview.asWebviewUri(extensionUri); @@ -45,18 +45,49 @@ export class WebViewContent { const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); const safeScriptUri = escapeHtml(scriptUri.toString()); + // Web Shell and the chrome strings read the locale from + // `document.documentElement.lang`; VS Code's own locale never reaches + // the webview unless it is injected here. + const language = escapeHtml(vscode.env.language || 'en'); + // The WebShell transcript bundles Shiki, whose Oniguruma engine compiles // WASM at runtime, and self-contained KaTeX fonts as data URLs, so the CSP // grants both wasm-unsafe-eval and data: fonts. - const csp = `default-src 'none'; img-src ${webview.cspSource} data:; font-src data:; script-src ${webview.cspSource} 'wasm-unsafe-eval'; style-src ${webview.cspSource} 'unsafe-inline';`; + const csp = `default-src 'none'; connect-src http://127.0.0.1:* ws://127.0.0.1:*; img-src ${webview.cspSource} data:; font-src data:; script-src ${webview.cspSource} 'wasm-unsafe-eval'; style-src ${webview.cspSource} 'unsafe-inline';`; return ` - + Qwen Code +
diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index abd008f878f..74eb206e7d0 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -147,6 +147,64 @@ vi.mock('@qwen-code/qwen-code-core', async () => { }; }); +const daemonMocks = vi.hoisted(() => { + // Contract-faithful stand-in for QwenDaemonProcess: workspace switches + // notify superseded listeners, exits notify exit listeners, and a disposed + // subscription stops receiving either. + class FakeQwenDaemonProcess { + boundCwd: string | null = null; + runtimeCount = 0; + exitListeners = new Set<() => void>(); + supersededListeners = new Set<() => void>(); + + async start(_cliEntryPath: string, workspaceCwd: string) { + if (this.boundCwd !== null && this.boundCwd !== workspaceCwd) { + for (const listener of [...this.supersededListeners]) listener(); + } + this.boundCwd = workspaceCwd; + this.runtimeCount += 1; + return { + baseUrl: `http://127.0.0.1:${4100 + this.runtimeCount}`, + token: `token-${this.runtimeCount}`, + }; + } + + addExitListener(listener: () => void) { + this.exitListeners.add(listener); + return { + dispose: () => { + this.exitListeners.delete(listener); + }, + }; + } + + addSupersededListener(listener: () => void) { + this.supersededListeners.add(listener); + return { + dispose: () => { + this.supersededListeners.delete(listener); + }, + }; + } + + dispose(): void {} + } + + return { + FakeQwenDaemonProcess, + instances: [] as FakeQwenDaemonProcess[], + }; +}); + +vi.mock('../../services/qwenDaemonProcess.js', () => ({ + QwenDaemonProcess: class extends daemonMocks.FakeQwenDaemonProcess { + constructor() { + super(); + daemonMocks.instances.push(this); + } + }, +})); + vi.mock('vscode', () => ({ ExtensionMode: { Production: 1, @@ -296,6 +354,7 @@ vi.mock('./PanelManager.js', async (importOriginal) => { return mockGetPanel(); } setPanel = vi.fn(); + dispose = vi.fn(); }, }; }); @@ -371,6 +430,7 @@ vi.mock('../../utils/errorMessage.js', () => ({ getErrorMessage: vi.fn((error: unknown) => String(error)), })); +import * as vscode from 'vscode'; import { WebViewProvider, resolveQwenCliEntryPath } from './WebViewProvider.js'; import { truncatePanelTitle, @@ -429,6 +489,7 @@ describe('resolveQwenCliEntryPath', () => { */ async function setupAttachedProvider(options?: { captureMessageHandler?: boolean; + context?: unknown; }) { let messageHandler: WebViewMessageHandler | undefined; @@ -451,7 +512,7 @@ async function setupAttachedProvider(options?: { }; const provider = new WebViewProvider( - { subscriptions: [] } as never, + (options?.context ?? { subscriptions: [] }) as never, { fsPath: '/extension-root' } as never, ); @@ -1504,6 +1565,74 @@ describe('WebViewProvider initial model inheritance', () => { ); expect(agentManager.setModelFromUi).toHaveBeenCalledWith('glm-5'); }); + + it('does not apply a discontinued initial model to the new session', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + provider.setInitialModelId('qwen3-coder-plus(qwen-oauth)'); + + const agentManager = ( + provider as unknown as { + agentManager: { + createNewSession: ReturnType; + setModelFromUi: ReturnType; + }; + } + ).agentManager; + agentManager.createNewSession.mockResolvedValue('session-1'); + agentManager.setModelFromUi.mockResolvedValue({ + modelId: 'qwen3-coder-plus(qwen-oauth)', + name: 'Qwen3 Coder Plus', + }); + + await ( + provider as unknown as { + loadCurrentSessionMessages: (options?: { + autoAuthenticate?: boolean; + }) => Promise; + } + ).loadCurrentSessionMessages(); + + expect(agentManager.setModelFromUi).not.toHaveBeenCalled(); + }); + + it('still applies a runtime snapshot id that wraps a discontinued model', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + provider.setInitialModelId( + '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + ); + + const agentManager = ( + provider as unknown as { + agentManager: { + createNewSession: ReturnType; + setModelFromUi: ReturnType; + }; + } + ).agentManager; + agentManager.createNewSession.mockResolvedValue('session-1'); + agentManager.setModelFromUi.mockResolvedValue({ + modelId: '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + name: 'Qwen3 Coder Plus', + }); + + await ( + provider as unknown as { + loadCurrentSessionMessages: (options?: { + autoAuthenticate?: boolean; + }) => Promise; + } + ).loadCurrentSessionMessages(); + + expect(agentManager.setModelFromUi).toHaveBeenCalledWith( + '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + ); + }); }); describe('Notification & dot indicator', () => { @@ -2125,3 +2254,150 @@ describe('WebViewProvider.handleAuthInteractive credential rollback', () => { ); }); }); + +describe('WebViewProvider web-shell daemon bootstrap', () => { + function setWorkspaceFolders(folders: string[]): void { + ( + vscode.workspace as unknown as { + workspaceFolders: Array<{ uri: { fsPath: string } }>; + } + ).workspaceFolders = folders.map((fsPath) => ({ uri: { fsPath } })); + } + + function createSharedContext(): unknown { + return { + subscriptions: [], + workspaceState: { + get: vi.fn(() => undefined), + update: vi.fn(() => Promise.resolve()), + }, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockMessageHandlerInstances.length = 0; + mockQwenAgentManagerInstances.length = 0; + mockGetPanel.mockReturnValue(null); + mockConfigGet.mockImplementation( + (_key: string, defaultValue: unknown) => defaultValue, + ); + daemonMocks.instances.length = 0; + setWorkspaceFolders(['/workspace-a']); + vi.spyOn( + WebViewProvider.prototype as unknown as { + initializeAgentConnection: () => Promise; + }, + 'initializeAgentConnection', + ).mockResolvedValue(undefined); + }); + + it('surfaces the failure to an attached webview when another host switches the shared daemon workspace', async () => { + const context = createSharedContext(); + const first = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + const second = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + + setWorkspaceFolders(['/workspace-a']); + await first.messageHandler?.({ type: 'webShellReady' }); + expect(first.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'webShellBootstrap', + data: expect.objectContaining({ workspaceCwd: '/workspace-a' }), + }), + ); + + // A second host bootstrapping against another folder replaces the + // daemon the first webview is streaming against. + setWorkspaceFolders(['/workspace-b']); + await second.messageHandler?.({ type: 'webShellReady' }); + + expect(second.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'webShellBootstrap', + data: expect.objectContaining({ workspaceCwd: '/workspace-b' }), + }), + ); + // The first webview must hear about the replacement — its baseUrl and + // token are dead and nothing else tells it. + expect(first.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'webShellBootstrapError' }), + ); + // The host that triggered the switch must not be told its own daemon died. + const secondErrors = second.postMessage.mock.calls.filter( + ([message]) => + (message as { type?: string }).type === 'webShellBootstrapError', + ); + expect(secondErrors).toHaveLength(0); + }); + + it('does not notify a host about a workspace switch it triggers itself', async () => { + const context = createSharedContext(); + const host = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + + setWorkspaceFolders(['/workspace-a']); + await host.messageHandler?.({ type: 'webShellReady' }); + expect(host.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'webShellBootstrap', + data: expect.objectContaining({ workspaceCwd: '/workspace-a' }), + }), + ); + + // The same host re-bootstrapping against another folder (a webview + // reload with a different active editor) replaces the daemon itself — + // it must not be told its own daemon died. + setWorkspaceFolders(['/workspace-b']); + await host.messageHandler?.({ type: 'webShellReady' }); + + expect(host.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'webShellBootstrap', + data: expect.objectContaining({ workspaceCwd: '/workspace-b' }), + }), + ); + const errors = host.postMessage.mock.calls.filter( + ([message]) => + (message as { type?: string }).type === 'webShellBootstrapError', + ); + expect(errors).toHaveLength(0); + }); + + it('keeps notifying live hosts after another host disposes', async () => { + const context = createSharedContext(); + const first = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + const second = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + + await first.messageHandler?.({ type: 'webShellReady' }); + await second.messageHandler?.({ type: 'webShellReady' }); + + // A disposed host's subscription must not swallow the crash notice for + // the hosts still alive. + first.provider.dispose(); + const daemon = daemonMocks.instances[0]; + for (const listener of [...daemon.exitListeners]) listener(); + + expect(second.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'webShellBootstrapError' }), + ); + const firstErrors = first.postMessage.mock.calls.filter( + ([message]) => + (message as { type?: string }).type === 'webShellBootstrapError', + ); + expect(firstErrors).toHaveLength(0); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 1fb191fc675..e29eaab0118 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { execFile } from 'child_process'; import { existsSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; import { QwenAgentManager } from '../../services/qwenAgentManager.js'; import { ConversationStore } from '../../services/conversationStore.js'; @@ -26,6 +27,7 @@ import { WebViewContent } from './WebViewContent.js'; import { getFileName } from '../utils/webviewUtils.js'; import { truncatePanelTitle } from '../utils/panelTitleUtils.js'; import { createImagePathResolver } from '../utils/imageHandler.js'; +import { isDiscontinuedModel } from '../utils/discontinuedModel.js'; import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; @@ -42,11 +44,29 @@ import { parseInsightMessage, } from '@qwen-code/qwen-code-core'; import { isLogLevel, logger } from '../../utils/logger.js'; +import { + QwenDaemonProcess, + type QwenDaemonListenerHandle, +} from '../../services/qwenDaemonProcess.js'; /** Threshold (ms) before a completed task triggers a notification. */ const LONG_TASK_THRESHOLD_MS = 20_000; const MAX_WEBVIEW_LOG_LENGTH = 10_000; +const daemonProcesses = new WeakMap< + vscode.ExtensionContext, + QwenDaemonProcess +>(); + +function getDaemonProcess(context: vscode.ExtensionContext): QwenDaemonProcess { + const current = daemonProcesses.get(context); + if (current) return current; + const daemon = new QwenDaemonProcess(); + daemonProcesses.set(context, daemon); + context.subscriptions.push(daemon); + return daemon; +} + /** Possible tab-dot colours. */ const DotColor = { /** Task completed while tab was not active. */ @@ -94,11 +114,39 @@ function isInsightCommand(command: string): boolean { return firstToken.replace(/^\/+/, '') === 'insight'; } +const WEB_SHELL_SESSION_STATE_PREFIX = 'qwenCode.webShellSessionId:'; + +function getRestorableDaemonSessionId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const sessionId = value.trim(); + if ( + !sessionId || + /^conv_/i.test(sessionId) || + /^temp(?:$|[-_])/i.test(sessionId) + ) { + return undefined; + } + return sessionId; +} + +function webShellSessionStateKey(workspaceCwd: string): string { + return `${WEB_SHELL_SESSION_STATE_PREFIX}${workspaceCwd}`; +} + export class WebViewProvider { private panelManager: PanelManager; private messageHandler: MessageHandler; private agentManager: QwenAgentManager; private conversationStore: ConversationStore; + private daemonProcess: QwenDaemonProcess; + /** Daemon subscriptions held by the most recent successful bootstrap. */ + private daemonListenerHandles: QwenDaemonListenerHandle[] = []; + /** + * Daemon client identity for this host. The daemon uses it to attribute + * session-scoped requests, so it has to stay stable across webview reloads + * (a reload re-runs the bootstrap) while staying distinct per chat host. + */ + private readonly daemonClientId = `vscode-${randomUUID()}`; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized private isSyncingToVSCode = false; // Guard to prevent config change loop @@ -153,6 +201,7 @@ export class WebViewProvider { private context: vscode.ExtensionContext, private extensionUri: vscode.Uri, ) { + this.daemonProcess = getDaemonProcess(context); this.agentManager = new QwenAgentManager(); this.conversationStore = new ConversationStore(context); this.panelManager = new PanelManager(extensionUri, () => { @@ -880,9 +929,8 @@ export class WebViewProvider { }); } - // Re-initialize when the view becomes visible after being hidden, - // in case the agent was never connected (e.g. sidebar opened but collapsed). - // Also reset dotState so it doesn't leak into a future editor-tab panel. + // Re-check authentication when the view becomes visible in case the + // initial connection was never established. webviewView.onDidChangeVisibility(() => { if (webviewView.visible) { this.dotState = null; @@ -900,10 +948,6 @@ export class WebViewProvider { this.disposables.forEach((d) => d.dispose()); }); - // Attempt to restore auth state and initialize connection - logger.log( - '[WebViewProvider] Attempting to restore auth state and connection for view...', - ); await this.attemptAuthStateRestoration(); } @@ -1080,10 +1124,6 @@ export class WebViewProvider { }); } - // Attempt to restore authentication state and initialize connection - logger.log( - '[WebViewProvider] Attempting to restore auth state and connection...', - ); await this.attemptAuthStateRestoration(); } @@ -1685,6 +1725,17 @@ export class WebViewProvider { const modelId = this.initialModelId; this.initialModelId = null; + // The discontinued Qwen OAuth free tier must not be re-applied to a + // fresh session through the initial-model route; the legacy setModel + // guard in SessionMessageHandler does not cover this path. + if (isDiscontinuedModel(modelId)) { + logger.warn( + '[WebViewProvider] Skipping discontinued initial model:', + modelId, + ); + return; + } + try { await this.agentManager.setModelFromUi(modelId); } catch (error) { @@ -1753,6 +1804,11 @@ export class WebViewProvider { case 'authError': this.authState = false; break; + case 'authCancelled': + if (this.authState === null) { + this.authState = false; + } + break; default: break; } @@ -1812,6 +1868,20 @@ export class WebViewProvider { } } + private async replayAuthState(webview: vscode.Webview): Promise { + const authenticated = + typeof this.authState === 'boolean' + ? this.authState + : this.agentInitialized + ? Boolean(this.agentManager.currentSessionId) + : null; + if (authenticated === null) return; + await webview.postMessage({ + type: 'authState', + data: { authenticated }, + }); + } + /** * Context-aware handler for the "New Chat" action (openNewChatTab message). * @@ -1862,6 +1932,128 @@ export class WebViewProvider { message: { type: string; data?: unknown }, webview: vscode.Webview, ): Promise { + if (message.type === 'webShellSessionChanged') { + const data = message.data as + | { sessionId?: unknown; workspaceCwd?: unknown } + | undefined; + const sessionId = getRestorableDaemonSessionId(data?.sessionId) ?? null; + this.messageHandler.setCurrentConversationId(sessionId); + if (this.isViewHost && typeof data?.workspaceCwd === 'string') { + await this.context.workspaceState.update( + webShellSessionStateKey(data.workspaceCwd), + sessionId ?? undefined, + ); + } + return true; + } + if (message.type === 'webShellReady') { + const workspaceCwd = + (vscode.window.activeTextEditor + ? vscode.workspace.getWorkspaceFolder( + vscode.window.activeTextEditor.document.uri, + )?.uri.fsPath + : undefined) ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceCwd) { + await webview.postMessage({ + type: 'webShellBootstrapError', + data: { message: 'Open a folder to use Qwen Code.' }, + }); + return true; + } + // Re-bootstrap replaces this host's previous daemon subscriptions; a + // workspace switch this host itself triggers must not notify its own + // stale listener. + for (const handle of this.daemonListenerHandles.splice(0)) { + handle.dispose(); + } + try { + const runtime = await this.daemonProcess.start( + resolveQwenCliEntryPath( + this.extensionUri, + this.context.extensionMode, + ), + workspaceCwd, + ); + const serializedSessionId = getRestorableDaemonSessionId( + this.messageHandler.getCurrentConversationId(), + ); + const viewSessionId = this.isViewHost + ? getRestorableDaemonSessionId( + this.context.workspaceState.get( + webShellSessionStateKey(workspaceCwd), + ), + ) + : undefined; + const restoredSessionId = this.isViewHost + ? viewSessionId + : serializedSessionId; + await webview.postMessage({ + type: 'webShellBootstrap', + data: { + ...runtime, + clientId: this.daemonClientId, + workspaceCwd, + hostKind: this.isViewHost ? 'view' : 'panel', + ...(restoredSessionId ? { sessionId: restoredSessionId } : {}), + }, + }); + // A daemon that dies after a successful start — or that gets + // replaced by another host's workspace switch — leaves this webview + // making requests against a dead port with no way to know; surface + // it so the panel can show the failure instead of silently hanging. + // The daemon is shared by every chat host, so each host holds its own + // subscription: a single overwritable slot only ever reached the + // last webview that bootstrapped. + const postDaemonFailure = (message: string) => { + void webview.postMessage({ + type: 'webShellBootstrapError', + data: { message }, + }); + }; + this.daemonListenerHandles.push( + this.daemonProcess.addExitListener(() => + postDaemonFailure( + 'Qwen Code stopped unexpectedly. Reload the panel to restart it.', + ), + ), + this.daemonProcess.addSupersededListener(() => + postDaemonFailure( + 'Qwen Code restarted against a different folder. Reload the panel to reconnect.', + ), + ), + ); + await this.replayAuthState(webview); + const editor = vscode.window.activeTextEditor; + if (editor) { + const filePath = editor.document.uri.fsPath || null; + await webview.postMessage({ + type: 'activeEditorChanged', + data: { + fileName: filePath ? getFileName(filePath) : null, + filePath, + selection: editor.selection.isEmpty + ? null + : { + startLine: editor.selection.start.line + 1, + endLine: editor.selection.end.line + 1, + }, + }, + }); + } + } catch (error) { + logger.error( + '[WebViewProvider] Failed to start WebShell daemon:', + error, + ); + await webview.postMessage({ + type: 'webShellBootstrapError', + data: { + message: error instanceof Error ? error.message : String(error), + }, + }); + } + return true; + } if (message.type === 'log') { const data = message.data as | { level?: unknown; message?: unknown } @@ -1879,7 +2071,8 @@ export class WebViewProvider { return true; } if (message.type === 'openDiff' && this.isAutoMode()) { - return true; + const source = (message.data as { source?: unknown } | undefined)?.source; + if (source !== 'web-shell') return true; } if (message.type === 'webviewReady') { this.handleWebviewReady(); @@ -2446,10 +2639,6 @@ export class WebViewProvider { logger.log('[WebViewProvider] Panel restored successfully'); - // Attempt to restore authentication state and initialize connection - logger.log( - '[WebViewProvider] Attempting to restore auth state and connection after restore...', - ); await this.attemptAuthStateRestoration(); } @@ -2563,6 +2752,9 @@ export class WebViewProvider { if (WebViewProvider.lastContextMenuProvider === this) { WebViewProvider.lastContextMenuProvider = null; } + for (const handle of this.daemonListenerHandles.splice(0)) { + handle.dispose(); + } this.panelManager.dispose(); this.agentManager.disconnect(); this.disposables.forEach((d) => d.dispose()); diff --git a/packages/vscode-ide-companion/src/webview/sessionSource.ts b/packages/vscode-ide-companion/src/webview/sessionSource.ts new file mode 100644 index 00000000000..914c7e3dec5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/sessionSource.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Creator attribution the companion stamps on every session it starts. + * + * The daemon is shared: the CLI, the browser Web Shell, and this extension all + * talk to the same `qwen serve` instance for a workspace, and Web Shell + * otherwise records `'default'` for every surface. Without a distinct value the + * VS Code channel is indistinguishable from a terminal or browser session, so + * the panel's history would list conversations the user never started here. + */ +export const VSCODE_SESSION_SOURCE_TYPE = 'vscode'; diff --git a/packages/vscode-ide-companion/src/webview/strings.test.ts b/packages/vscode-ide-companion/src/webview/strings.test.ts new file mode 100644 index 00000000000..3e1ad5e494d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/strings.test.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { readLanguage } from './strings.js'; + +describe('readLanguage', () => { + afterEach(() => { + document.documentElement.lang = ''; + }); + + it('resolves zh-CN from the webview language', () => { + document.documentElement.lang = 'zh-cn'; + expect(readLanguage()).toBe('zh-CN'); + }); + + it('falls back to the navigator language when lang is unset', () => { + document.documentElement.lang = ''; + expect(readLanguage()).toBe('en'); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/strings.ts b/packages/vscode-ide-companion/src/webview/strings.ts new file mode 100644 index 00000000000..9c5d29735f4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/strings.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Copy for the VS Code-native chrome the companion draws around Web Shell — + * the view header, the conversation history dropdown, onboarding, the account + * dialog, and the notice bar. + * + * Web Shell localizes its own surface from the same `language` signal, so + * without this the panel renders a Chinese transcript under an English header. + */ + +export type ChromeLanguage = 'en' | 'zh-CN'; + +/** Resolve the webview language the same way Web Shell's `language` prop does. */ +export function readLanguage(): ChromeLanguage { + const language = document.documentElement.lang || navigator.language; + return language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; +} + +const EN = { + 'header.history': 'Past conversations', + 'header.newSession': 'New session', + 'session.new': 'New Session', + 'session.untitled': 'Untitled', + 'session.past': 'Past Conversations', + 'session.searchPlaceholder': 'Search sessions…', + 'session.searchLabel': 'Search conversations', + 'session.listLabel': 'Conversations', + 'session.closeHistory': 'Close conversation history', + 'session.rename': 'Rename', + 'session.renameLabel': 'Rename conversation', + 'session.delete': 'Delete', + 'session.deleteLabel': 'Delete conversation', + 'session.deleteConfirm': 'Delete?', + 'session.deleteConfirmLabel': 'Confirm deleting this conversation', + 'session.empty': 'No sessions available', + 'session.emptyFiltered': 'No matching sessions', + 'session.loading': 'Loading…', + 'askUser.other': 'Other...', + 'session.loadFailed': 'Failed to load sessions.', + 'session.renameFailed': 'Failed to rename session.', + 'session.deleteFailed': 'Failed to delete session.', + 'session.switching': 'Loading conversation…', + 'session.creating': 'Starting new session…', + 'session.createFailed': 'Failed to create a new session.', + 'session.loadError': 'Failed to load the Qwen Code session.', + 'session.switchTimeout': 'The conversation switch timed out. Try again.', + 'group.today': 'Today', + 'group.yesterday': 'Yesterday', + 'group.thisWeek': 'This Week', + 'group.older': 'Older', + 'time.now': 'now', + 'boot.starting': 'Starting Qwen Code...', + 'boot.failed': 'Qwen Code failed to start.', + 'boot.noFolder': 'Open a folder to use Qwen Code.', + 'onboarding.title': 'Qwen Code', + 'onboarding.subtitle': 'Connect a model provider to start coding with Qwen.', + 'onboarding.cta': 'Get Started', + 'onboarding.connecting': 'Connecting…', + 'onboarding.providers': + 'Supports Coding Plan, ModelStudio API Key, and OpenAI-compatible providers.', + 'auth.signedIn': 'Signed in successfully.', + 'auth.failed': 'Failed to connect to Qwen Code.', + 'account.title': 'Account Information', + 'account.authType': 'Auth Method', + 'account.envKey': 'API Key Env', + 'account.baseUrl': 'Base URL', + 'account.model': 'Current Model', + 'account.unknown': 'Unknown', + 'account.error': 'Error', + 'composer.addContext': 'Add context', + 'composer.placeholder': 'Ask Qwen Code or @ a file', + 'composer.editing': 'Editing message', + 'composer.cancelEditing': 'Cancel editing', + 'composer.editUnavailable': + 'The message cannot be edited before the session is ready.', + 'composer.editExpired': 'The original message can no longer be edited.', + 'context.included': 'Included', + 'context.excluded': 'Excluded', + 'context.include': 'Include active file context', + 'context.exclude': 'Exclude active file context', + 'insight.progressDetail': 'Processing your chat history…', + 'insight.ready': 'Insight report generated:', + 'notice.open': 'Open', + 'notice.dismiss': 'Dismiss', + 'cmd.model.label': 'Switch model...', + 'cmd.model.description': 'Switch the active model', + 'cmd.auth.description': 'Configure Coding Plan or API Key', + 'cmd.account.label': 'Account', + 'cmd.account.description': 'Show current account and authentication info', + 'cmd.export.description': 'Export the current conversation', + 'cmd.section.model': 'Model', + 'cmd.section.account': 'Account', + 'cmd.section.session': 'Session', + 'common.close': 'Close', +} as const; + +export type ChromeStringKey = keyof typeof EN; + +const ZH: Record = { + 'header.history': '历史会话', + 'header.newSession': '新建会话', + 'session.new': '新会话', + 'session.untitled': '未命名', + 'session.past': '历史会话', + 'session.searchPlaceholder': '搜索会话…', + 'session.searchLabel': '搜索会话', + 'session.listLabel': '会话列表', + 'session.closeHistory': '关闭历史会话', + 'session.rename': '重命名', + 'session.renameLabel': '重命名会话', + 'session.delete': '删除', + 'session.deleteLabel': '删除会话', + 'session.deleteConfirm': '确认删除?', + 'session.deleteConfirmLabel': '确认删除该会话', + 'session.empty': '暂无会话', + 'session.emptyFiltered': '没有匹配的会话', + 'session.loading': '加载中…', + 'askUser.other': '其他...', + 'session.loadFailed': '加载会话列表失败。', + 'session.renameFailed': '重命名会话失败。', + 'session.deleteFailed': '删除会话失败。', + 'session.switching': '正在加载会话…', + 'session.creating': '正在创建新会话…', + 'session.createFailed': '创建新会话失败。', + 'session.loadError': '加载 Qwen Code 会话失败。', + 'session.switchTimeout': '会话切换超时,请重试。', + 'group.today': '今天', + 'group.yesterday': '昨天', + 'group.thisWeek': '本周', + 'group.older': '更早', + 'time.now': '刚刚', + 'boot.starting': '正在启动 Qwen Code…', + 'boot.failed': 'Qwen Code 启动失败。', + 'boot.noFolder': '请先打开一个文件夹以使用 Qwen Code。', + 'onboarding.title': 'Qwen Code', + 'onboarding.subtitle': '连接一个模型服务商,开始使用 Qwen 编码。', + 'onboarding.cta': '开始使用', + 'onboarding.connecting': '连接中…', + 'onboarding.providers': + '支持 Coding Plan、百炼 API Key 以及兼容 OpenAI 的服务商。', + 'auth.signedIn': '登录成功。', + 'auth.failed': '连接 Qwen Code 失败。', + 'account.title': '账号信息', + 'account.authType': '认证方式', + 'account.envKey': 'API Key 环境变量', + 'account.baseUrl': 'Base URL', + 'account.model': '当前模型', + 'account.unknown': '未知', + 'account.error': '错误', + 'composer.addContext': '添加上下文', + 'composer.placeholder': '向 Qwen Code 提问,或用 @ 引用文件', + 'composer.editing': '正在编辑消息', + 'composer.cancelEditing': '取消编辑', + 'composer.editUnavailable': '会话尚未就绪,暂时无法编辑该消息。', + 'composer.editExpired': '该消息已无法再编辑。', + 'context.included': '已包含', + 'context.excluded': '已排除', + 'context.include': '包含当前文件上下文', + 'context.exclude': '排除当前文件上下文', + 'insight.progressDetail': '正在分析你的对话历史…', + 'insight.ready': '洞察报告已生成:', + 'notice.open': '打开', + 'notice.dismiss': '关闭', + 'cmd.model.label': '切换模型…', + 'cmd.model.description': '切换当前使用的模型', + 'cmd.auth.description': '配置 Coding Plan 或 API Key', + 'cmd.account.label': '账号', + 'cmd.account.description': '查看当前账号与认证信息', + 'cmd.export.description': '导出当前对话', + 'cmd.section.model': '模型', + 'cmd.section.account': '账号', + 'cmd.section.session': '会话', + 'common.close': '关闭', +}; + +export type ChromeStrings = (key: ChromeStringKey) => string; + +/** Build the lookup for one language. Falls back to English for any gap. */ +export function createChromeStrings(language: ChromeLanguage): ChromeStrings { + const table = language === 'zh-CN' ? ZH : EN; + return (key) => table[key] || EN[key]; +} diff --git a/packages/vscode-ide-companion/src/webview/styles/App.css b/packages/vscode-ide-companion/src/webview/styles/App.css deleted file mode 100644 index 97016701f2e..00000000000 --- a/packages/vscode-ide-companion/src/webview/styles/App.css +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * VSCode IDE Companion - Theme Variables - * - * This file ONLY contains CSS variable definitions that map VSCode design tokens - * to the --app-* variables used by @qwen-code/webui components. - * - * Component styles are in @qwen-code/webui package and use these variables. - * No class overrides needed - just set the variables correctly. - */ - -/* =========================== - VSCode Theme Variables - =========================== */ -:root { - /* Qwen Brand Colors - these override webui defaults */ - --app-qwen-theme: #615fff; - --app-qwen-clay-button-orange: #4f46e5; - --app-qwen-ivory: #f5f5ff; - --app-qwen-slate: #141420; - --app-qwen-green: #6bcf7f; - - /* Primary color - components use var(--app-primary) */ - --app-primary: var(--app-qwen-theme); - --app-primary-hover: var(--app-qwen-clay-button-orange); - - /* Spacing */ - --app-spacing-small: 4px; - --app-spacing-medium: 8px; - --app-spacing-large: 12px; - --app-spacing-xlarge: 16px; - --app-spacing-sm: var(--app-spacing-small); - --app-spacing-md: var(--app-spacing-medium); - --app-spacing-lg: var(--app-spacing-large); - - /* Border Radius */ - --corner-radius-small: 4px; - --corner-radius-medium: 6px; - --corner-radius-large: 8px; - --app-radius-sm: var(--corner-radius-small); - --app-radius-md: var(--corner-radius-medium); - --app-radius-lg: var(--corner-radius-large); - - /* Typography - VSCode tokens */ - --app-font-mono: var(--vscode-editor-font-family, monospace); - --app-font-sans: var( - --vscode-chat-font-family, - var(--vscode-font-family, system-ui, sans-serif) - ); - --app-monospace-font-family: var(--vscode-editor-font-family, monospace); - --app-monospace-font-size: var(--vscode-editor-font-size, 12px); - - /* Foreground & Background - VSCode tokens */ - --app-foreground: var(--vscode-foreground); - --app-primary-foreground: var(--vscode-foreground); - --app-secondary-foreground: var(--vscode-descriptionForeground); - --app-background: var(--vscode-sideBar-background); - --app-primary-background: var(--vscode-sideBar-background); - --app-background-secondary: var(--vscode-menu-background); - --app-secondary-background: var(--vscode-menu-background); - --app-primary-border-color: var(--vscode-sideBarActivityBarTop-border); - - /* Input Colors - VSCode tokens */ - --app-input-foreground: var(--vscode-input-foreground); - --app-input-background: var(--vscode-input-background); - --app-input-border: var(--vscode-inlineChatInput-border); - --app-input-active-border: var(--vscode-inputOption-activeBorder); - --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); - --app-input-secondary-background: var(--vscode-menu-background); - - /* Code & Links - VSCode tokens */ - --app-code-background: var( - --vscode-textCodeBlock-background, - rgba(0, 0, 0, 0.05) - ); - --app-tool-background: var(--vscode-editor-background); - --app-link-foreground: var(--vscode-textLink-foreground, #007acc); - --app-link-active-foreground: var( - --vscode-textLink-activeForeground, - #005a9e - ); - - /* List Styles - VSCode tokens */ - --app-list-hover-background: var(--vscode-list-hoverBackground); - --app-list-active-background: var(--vscode-list-activeSelectionBackground); - --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); - --app-list-padding: 0px; - --app-list-item-padding: 4px 8px; - --app-list-border-color: transparent; - --app-list-border-radius: 4px; - --app-list-gap: 2px; - - /* Scrollbars - VSCode tokens */ - --app-scrollbar-thumb: var( - --vscode-scrollbarSlider-background, - rgba(128, 128, 128, 0.35) - ); - --app-scrollbar-thumb-hover: var( - --vscode-scrollbarSlider-hoverBackground, - rgba(128, 128, 128, 0.5) - ); - - /* Buttons - VSCode tokens */ - --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); - --app-button-foreground: var( - --vscode-button-foreground, - var(--app-qwen-ivory) - ); - --app-button-background: var( - --vscode-button-background, - var(--app-qwen-clay-button-orange) - ); - --app-button-hover-background: var(--vscode-button-hoverBackground); - - /* Border Transparency */ - --app-transparent-inner-border: rgba(255, 255, 255, 0.1); - - /* Header - VSCode tokens */ - --app-header-background: var(--vscode-sideBar-background); - - /* Menu Colors - VSCode tokens */ - --app-menu-background: var(--vscode-menu-background); - --app-menu-border: var(--vscode-menu-border); - --app-menu-foreground: var(--vscode-menu-foreground); - --app-menu-selection-background: var(--vscode-menu-selectionBackground); - --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); - - /* Modal */ - --app-modal-background: rgba(0, 0, 0, 0.75); - - /* Widget - VSCode tokens */ - --app-widget-border: var(--vscode-editorWidget-border); - --app-widget-shadow: var(--vscode-widget-shadow); - - /* Status Colors */ - --app-success: #10b981; - --app-warning: #f59e0b; - --app-error: #ef4444; - - /* Warning/Error Styles - VSCode specific */ - --app-warning-background: var( - --vscode-editorWarning-background, - rgba(255, 204, 0, 0.1) - ); - --app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00); - --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); -} - -/* Light Theme Overrides */ -.vscode-light { - --app-transparent-inner-border: rgba(0, 0, 0, 0.07); -} - -/* =========================== - Global Reset & Base Styles - (VSCode webview specific) - =========================== */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: var(--app-font-sans); - background-color: var(--app-primary-background); - color: var(--app-primary-foreground); - overflow: hidden; - font-size: var(--vscode-chat-font-size, 13px); - padding: 0; -} - -button { - color: var(--app-primary-foreground); - font-family: var(--app-font-sans); - font-size: var(--vscode-chat-font-size, 13px); -} - -/* VSCode panel uses 100vh instead of 100% */ -.chat-container { - height: 100vh; - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.messages-container::-webkit-scrollbar-thumb { - background: var(--app-scrollbar-thumb); - border-radius: var(--corner-radius-small); -} - -.messages-container::-webkit-scrollbar-thumb:hover { - background: var(--app-scrollbar-thumb-hover); -} diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css deleted file mode 100644 index ae7be5c58a7..00000000000 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * VSCode IDE Companion - Tailwind CSS - * - * Only Tailwind directives and minimal VSCode-specific utilities. - * Component styles are in @qwen-code/webui package. - */ - -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* =========================== - VSCode-specific Utilities - =========================== */ -@layer utilities { - /* Multi-line clamp with ellipsis (Chromium-based webview supported) */ - .q-line-clamp-3 { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - } -} diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts deleted file mode 100644 index c83de5b7ea4..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { - isSkillsSecondaryQuery, - resolveCompletionTrigger, - shouldOpenSkillsSecondaryPicker, -} from './completionUtils.js'; - -const skillsCommandItem: CompletionItem = { - id: 'skills', - label: '/skills', - type: 'command', - value: 'skills', -}; - -describe('completionUtils', () => { - describe('isSkillsSecondaryQuery', () => { - it('matches /skills subqueries with trailing space', () => { - expect(isSkillsSecondaryQuery('skills ')).toBe(true); - expect(isSkillsSecondaryQuery('skills review')).toBe(true); - expect(isSkillsSecondaryQuery('skills code review')).toBe(true); - }); - - it('does not treat bare /skills as a secondary query', () => { - expect(isSkillsSecondaryQuery('skills')).toBe(false); - expect(isSkillsSecondaryQuery('compress')).toBe(false); - }); - }); - - describe('shouldOpenSkillsSecondaryPicker', () => { - it('opens the secondary picker only when skills are available', () => { - expect( - shouldOpenSkillsSecondaryPicker(skillsCommandItem, ['review', 'test']), - ).toBe(true); - expect(shouldOpenSkillsSecondaryPicker(skillsCommandItem, [])).toBe( - false, - ); - }); - - it('does not open for non-/skills commands', () => { - expect( - shouldOpenSkillsSecondaryPicker( - { - id: 'compress', - label: '/compress', - type: 'command', - value: 'compress', - }, - ['review'], - ), - ).toBe(false); - }); - }); - - describe('resolveCompletionTrigger', () => { - const at = (text: string) => resolveCompletionTrigger(text, text.length); - - it('falls back to a valid / when the last @ is inside a word (e.g. email)', () => { - // Regression: a non-boundary @ (inside "foo@bar.com") must not suppress - // the slash-command menu for a later, valid / trigger. - const text = 'contact foo@bar.com /he'; - expect(at(text)).toEqual({ char: '/', pos: 20, query: 'he' }); - }); - - it('keeps a / that is part of an @ mention path inside the mention', () => { - // The @ is at a word boundary, so it wins and the path-like / is not - // treated as a slash command. - expect(at('@src/components/Bu')).toEqual({ - char: '@', - pos: 0, - query: 'src/components/Bu', - }); - }); - - it('resolves an @ mention after a space', () => { - expect(at('hello @wor')).toEqual({ char: '@', pos: 6, query: 'wor' }); - }); - - it('treats a newline as a word boundary for @', () => { - expect(at('hello\n@wor')).toEqual({ char: '@', pos: 6, query: 'wor' }); - }); - - it('resolves a / slash command at the start of input', () => { - expect(at('/he')).toEqual({ char: '/', pos: 0, query: 'he' }); - }); - - it('returns null when the only @ is inside a word and there is no /', () => { - expect(at('email foo@bar.com')).toBeNull(); - }); - - it('returns null when / is not at a word boundary and no valid @ exists', () => { - expect(at('foo/bar')).toBeNull(); - }); - - it('returns null when neither trigger is present', () => { - expect(at('just some text')).toBeNull(); - }); - - it('resolves the trigger relative to the cursor, ignoring text after it', () => { - // Cursor sits right after "/he" in "/help world". - expect(resolveCompletionTrigger('/help world', 3)).toEqual({ - char: '/', - pos: 0, - query: 'he', - }); - }); - - it('gives @ priority over / when both are at word boundaries', () => { - // Both triggers are valid (/ at pos 0, @ after a space); @ wins by design. - expect(at('/cmd @user')).toEqual({ char: '@', pos: 5, query: 'user' }); - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts deleted file mode 100644 index edb977631c4..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Utility helpers for the /skills secondary completion picker. - */ - -import type { CompletionItem } from '../../types/completionItemTypes.js'; - -/** - * Prefix used to distinguish skill completion items from other commands. - * For example, a skill named "code-review" gets item id "skill:code-review". - */ -export const SKILL_ITEM_ID_PREFIX = 'skill:'; - -/** - * Check whether the current completion query is targeting the secondary - * skills picker (i.e. the user typed "/skills " followed by optional text). - * - * @param query - The text after the "/" trigger character - * @returns true when the query matches the "skills " pattern - */ -export function isSkillsSecondaryQuery(query: string): boolean { - return /^skills\s+/i.test(query); -} - -/** - * Determine whether selecting this completion item should open the - * secondary skills picker instead of sending the command immediately. - * - * @param item - The completion item the user selected - * @param availableSkills - Skills advertised by the backend for the picker - * @returns true when the item represents the /skills command and there are - * available skills to show - */ -export function shouldOpenSkillsSecondaryPicker( - item: CompletionItem, - availableSkills: string[], -): boolean { - return ( - item.type === 'command' && - item.id === 'skills' && - availableSkills.length > 0 - ); -} - -/** - * Resolve which completion trigger (`@` or `/`), if any, is active immediately - * before the cursor. - * - * A trigger only counts at a word boundary — the start of the input, or right - * after a space/newline. A valid `@` takes precedence over `/` so that - * path-like queries stay part of an `@` mention (e.g. `@src/components/Button` - * is a single mention, not a slash command). Crucially, an `@` that is NOT at - * a word boundary — for example inside an email like `foo@bar.com` — is not a - * trigger at all, so we fall through and still evaluate a later `/`. Without - * this, typing `foo@bar.com /he` would let the unrelated `@` suppress the - * slash-command menu entirely. - * - * @param text - The full input text - * @param cursorPosition - Cursor offset into `text` (already clamped to length) - * @returns The active trigger's character, position, and the query following - * it, or `null` when there is no valid trigger before the cursor. - */ -export function resolveCompletionTrigger( - text: string, - cursorPosition: number, -): { char: '@' | '/'; pos: number; query: string } | null { - const textBeforeCursor = text.substring(0, cursorPosition); - const lastAtMatch = textBeforeCursor.lastIndexOf('@'); - const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); - - const isAtWordBoundary = (pos: number): boolean => - pos === 0 || text[pos - 1] === ' ' || text[pos - 1] === '\n'; - - let pos = -1; - let char: '@' | '/' | null = null; - if (lastAtMatch >= 0 && isAtWordBoundary(lastAtMatch)) { - pos = lastAtMatch; - char = '@'; - } else if (lastSlashMatch >= 0 && isAtWordBoundary(lastSlashMatch)) { - pos = lastSlashMatch; - char = '/'; - } - - if (pos < 0 || !char) { - return null; - } - - return { char, pos, query: text.substring(pos + 1, cursorPosition) }; -} diff --git a/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts b/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts deleted file mode 100644 index d1e07ca185c..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/contextUsage.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { computeContextUsage } from './contextUsage.js'; - -describe('computeContextUsage', () => { - it('returns null when there is no trusted token limit', () => { - expect( - computeContextUsage( - { - usage: { - promptTokens: 1234, - }, - }, - { - modelId: 'unknown-model', - name: 'Unknown Model', - }, - ), - ).toBeNull(); - }); - - it('prefers usageStats.tokenLimit over model metadata', () => { - expect( - computeContextUsage( - { - usage: { - promptTokens: 1000, - }, - tokenLimit: 4000, - }, - { - modelId: 'qwen3-max', - name: 'Qwen3 Max', - _meta: { contextLimit: 8000 }, - }, - ), - ).toEqual({ - percentLeft: 75, - usedTokens: 1000, - tokenLimit: 4000, - }); - }); - - it('falls back to model metadata when usageStats does not include a limit', () => { - expect( - computeContextUsage( - { - usage: { - promptTokens: 2000, - }, - }, - { - modelId: 'qwen3-max', - name: 'Qwen3 Max', - _meta: { contextLimit: 8000 }, - }, - ), - ).toEqual({ - percentLeft: 75, - usedTokens: 2000, - tokenLimit: 8000, - }); - }); - - it('uses inputTokens when promptTokens is unavailable', () => { - expect( - computeContextUsage( - { - usage: { - inputTokens: 3000, - }, - tokenLimit: 12000, - }, - null, - ), - ).toEqual({ - percentLeft: 75, - usedTokens: 3000, - tokenLimit: 12000, - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts b/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts deleted file mode 100644 index 394cdb03611..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/contextUsage.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ModelInfo } from '@agentclientprotocol/sdk'; -import type { ContextUsage } from '@qwen-code/webui'; -import type { UsageStatsPayload } from '../../types/chatTypes.js'; - -export function computeContextUsage( - usageStats: UsageStatsPayload | null, - modelInfo: ModelInfo | null, -): ContextUsage | null { - if (!usageStats && !modelInfo) { - return null; - } - - const metaLimitRaw = modelInfo?._meta?.['contextLimit']; - const metaLimit = - typeof metaLimitRaw === 'number' || metaLimitRaw === null - ? metaLimitRaw - : undefined; - // Intentionally avoid DEFAULT_TOKEN_LIMIT here. The footer should disappear - // when neither ACP nor trusted model metadata provides a numeric limit. - const limit = usageStats?.tokenLimit ?? metaLimit; - // Prefer the ACP SDK's canonical inputTokens field and only fall back to the - // legacy promptTokens name for older payloads. - const used = - usageStats?.usage?.inputTokens ?? usageStats?.usage?.promptTokens ?? 0; - - if (typeof limit !== 'number' || limit <= 0 || used < 0) { - return null; - } - - const percentLeft = Math.max( - 0, - Math.min(100, Math.round(((limit - used) / limit) * 100)), - ); - - return { - percentLeft, - usedTokens: used, - tokenLimit: limit, - }; -} diff --git a/packages/vscode-ide-companion/src/webview/utils/copyTranscript.ts b/packages/vscode-ide-companion/src/webview/utils/copyTranscript.ts index 4af6283d9a9..c43c07803a1 100644 --- a/packages/vscode-ide-companion/src/webview/utils/copyTranscript.ts +++ b/packages/vscode-ide-companion/src/webview/utils/copyTranscript.ts @@ -17,12 +17,34 @@ import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +function getTextBlockCopyText( + block: Extract< + DaemonTranscriptBlock, + { kind: 'user' | 'assistant' | 'thought' } + >, +): string | null { + const parts = [ + block.text.trim(), + ...(block.images ?? []).map((image) => `![image](${image.data})`), + ].filter(Boolean); + return parts.length > 0 ? parts.join('\n\n') : null; +} + +function buildFence(content: string): string { + const longestRun = Math.max( + 0, + ...Array.from(content.matchAll(/`+/g), (match) => match[0].length), + ); + return '`'.repeat(Math.max(3, longestRun + 1)); +} + /** Plain-text payload of one block, or null when it carries no copyable text. */ export function getBlockCopyText(block: DaemonTranscriptBlock): string | null { switch (block.kind) { case 'user': case 'assistant': case 'thought': + return getTextBlockCopyText(block); case 'status': case 'error': case 'debug': @@ -89,7 +111,10 @@ export function formatBlocksForCopyAll( } if (block.kind === 'tool') { const label = block.toolKind ?? block.toolName ?? 'tool'; - parts.push(`**[Tool: ${label}]**\n\n${content}`); + const toolParts = getToolContentCopyParts(block, true); + parts.push( + `**[Tool: ${label}]**\n\n${toolParts.length > 0 ? toolParts.join('\n\n') : content}`, + ); } else if (block.kind === 'status') { parts.push(`**Status:** ${content}`); } else if (block.kind === 'user_shell') { @@ -110,7 +135,7 @@ export function formatBlocksForCopyAll( ) { continue; } - const content = block.text.trim(); + const content = getTextBlockCopyText(block); if (!content) { continue; } @@ -143,7 +168,10 @@ export function findLastAssistantText( * Mirrors the shapes `normalizeToolContent` accepts in web-shell and the * `---/+++` diff rendering of the pre-PR `formatToolCallForCopy`. */ -function getToolContentCopyParts(block: { content?: unknown }): string[] { +function getToolContentCopyParts( + block: { content?: unknown }, + wrapCodeBlock = false, +): string[] { if (!Array.isArray(block.content)) { return []; } @@ -160,7 +188,12 @@ function getToolContentCopyParts(block: { content?: unknown }): string[] { typeof body === 'object' && typeof (body as Record).text === 'string' ) { - parts.push((body as Record).text as string); + const text = (body as Record).text as string; + parts.push( + wrapCodeBlock + ? `${buildFence(text)}\n${text}\n${buildFence(text)}` + : text, + ); } continue; } @@ -176,11 +209,19 @@ function getToolContentCopyParts(block: { content?: unknown }): string[] { .split('\n') .map((line) => `+${line}`) .join('\n'); + const diff = `--- ${filePath}\n+++ ${filePath}\n${oldLines}\n${newLines}`; parts.push( - `--- ${filePath}\n+++ ${filePath}\n${oldLines}\n${newLines}`, + wrapCodeBlock + ? `${buildFence(diff)}diff\n${diff}\n${buildFence(diff)}` + : diff, ); } else { - parts.push(`${filePath}:\n${record.newText}`); + const text = record.newText; + parts.push( + wrapCodeBlock + ? `${filePath}:\n${buildFence(text)}\n${text}\n${buildFence(text)}` + : `${filePath}:\n${text}`, + ); } } } diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts deleted file mode 100644 index 19431a67976..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Re-export session grouping utilities from webui for backward compatibility - */ - -export { groupSessionsByDate, getTimeAgo } from '@qwen-code/webui'; -export type { SessionGroup } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts deleted file mode 100644 index 75edecc3f01..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import type { AvailableCommand } from '@agentclientprotocol/sdk'; -import { - buildSlashCommandItems, - isExpandableSlashCommand, - shouldAllowCompletionQuery, -} from './slashCommandUtils.js'; - -const availableCommands: AvailableCommand[] = [ - { - name: 'export', - description: - 'Export current session to a file. Available formats: html, md, json, jsonl.', - input: null, - }, - { - name: 'help', - description: 'Show help', - input: null, - }, -]; - -describe('slashCommandUtils', () => { - describe('shouldAllowCompletionQuery', () => { - it('keeps slash completion open when the query contains spaces', () => { - expect(shouldAllowCompletionQuery('/', 'export ')).toBe(true); - expect(shouldAllowCompletionQuery('/', 'export md')).toBe(true); - }); - - it('still blocks @ completion when the query contains spaces', () => { - expect(shouldAllowCompletionQuery('@', 'foo bar')).toBe(false); - }); - }); - - describe('buildSlashCommandItems', () => { - it('returns top-level slash commands for prefix queries', () => { - const items = buildSlashCommandItems('exp', availableCommands); - - expect(items.map((item) => item.id)).toContain('export'); - }); - - it('returns export subcommands for an exact /export parent query', () => { - const items = buildSlashCommandItems('export ', availableCommands); - - expect(items.map((item) => item.value)).toEqual([ - 'export html', - 'export md', - 'export json', - 'export jsonl', - ]); - }); - - it('filters export subcommands by the typed child query', () => { - const items = buildSlashCommandItems('export j', availableCommands); - - expect(items.map((item) => item.value)).toEqual([ - 'export json', - 'export jsonl', - ]); - }); - }); - - describe('isExpandableSlashCommand', () => { - it('marks /export as an expandable command', () => { - expect(isExpandableSlashCommand('export')).toBe(true); - expect(isExpandableSlashCommand('help')).toBe(false); - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts b/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts deleted file mode 100644 index 83d6245dba9..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/slashCommandUtils.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { AvailableCommand } from '@agentclientprotocol/sdk'; -import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { - EXPORT_PARENT_COMMAND_NAME, - EXPORT_SUBCOMMAND_SPECS, -} from '../../utils/exportSlashCommand.js'; - -export function shouldAllowCompletionQuery( - trigger: '@' | '/', - query: string, -): boolean { - if (query.includes('\n')) { - return false; - } - - if (trigger === '/') { - return true; - } - - return !query.includes(' '); -} - -export function isExpandableSlashCommand(commandName: string): boolean { - return commandName === EXPORT_PARENT_COMMAND_NAME; -} - -function matchesQuery( - query: string, - label: string, - description?: string, -): boolean { - const normalizedQuery = query.toLowerCase(); - return ( - label.toLowerCase().includes(normalizedQuery) || - (description?.toLowerCase().includes(normalizedQuery) ?? false) - ); -} - -function buildExportSubcommandItems(childQuery: string): CompletionItem[] { - return EXPORT_SUBCOMMAND_SPECS.filter((subcommand) => - matchesQuery( - childQuery, - `/export ${subcommand.name}`, - subcommand.description, - ), - ).map((subcommand) => ({ - id: `export:${subcommand.name}`, - label: `/${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`, - description: subcommand.description, - type: 'command' as const, - group: 'Slash Commands', - value: `${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`, - })); -} - -export function buildSlashCommandItems( - query: string, - availableCommands: readonly AvailableCommand[], -): CompletionItem[] { - const normalizedQuery = query.trimStart().toLowerCase(); - - if ( - normalizedQuery === EXPORT_PARENT_COMMAND_NAME || - normalizedQuery.startsWith(`${EXPORT_PARENT_COMMAND_NAME} `) - ) { - const childQuery = - normalizedQuery === EXPORT_PARENT_COMMAND_NAME - ? '' - : normalizedQuery - .slice(EXPORT_PARENT_COMMAND_NAME.length + 1) - .trimStart(); - return buildExportSubcommandItems(childQuery); - } - - return availableCommands - .map((cmd) => ({ - id: cmd.name, - label: `/${cmd.name}`, - description: cmd.description, - type: 'command' as const, - group: 'Slash Commands', - value: cmd.name, - })) - .filter((item) => matchesQuery(query, item.label, item.description)); -} diff --git a/packages/vscode-ide-companion/src/webview/utils/utils.test.ts b/packages/vscode-ide-companion/src/webview/utils/utils.test.ts deleted file mode 100644 index cdf8f12a72b..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/utils.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Unit tests for toolcall utility functions - */ - -import { describe, it, expect } from 'vitest'; -import { extractCommandOutput, formatValue } from './utils.js'; - -describe('extractCommandOutput', () => { - it('should extract output from JSON format', () => { - const input = JSON.stringify({ output: 'Hello World' }); - expect(extractCommandOutput(input)).toBe('Hello World'); - }); - - it('should handle uppercase Output in JSON', () => { - const input = JSON.stringify({ Output: 'Test Output' }); - expect(extractCommandOutput(input)).toBe('Test Output'); - }); - - it('should extract output from structured text format', () => { - const input = `Command: lsof -i :5173 -Directory: (root) -Output: COMMAND PID USER FD TYPE -node 59117 jinjing 17u IPv6 -Error: (none) -Exit Code: 0`; - - const output = extractCommandOutput(input); - expect(output).toContain('COMMAND PID USER'); - expect(output).toContain('node 59117 jinjing'); - expect(output).not.toContain('Command:'); - expect(output).not.toContain('Error:'); - }); - - it('should handle multiline output correctly', () => { - const input = `Command: ps aux -Directory: /home/user -Output: USER PID %CPU %MEM -root 1 0.0 0.1 -user 1234 1.5 2.3 -Error: (none) -Exit Code: 0`; - - const output = extractCommandOutput(input); - expect(output).toContain('USER PID %CPU %MEM'); - expect(output).toContain('root 1'); - expect(output).toContain('user 1234'); - }); - - it('should skip (none) output', () => { - const input = `Command: test -Output: (none) -Error: (none)`; - - const output = extractCommandOutput(input); - expect(output).toBe(input); // Should return original if output is (none) - }); - - it('should return original text if no structured format found', () => { - const input = 'Just some random text'; - expect(extractCommandOutput(input)).toBe(input); - }); - - it('should handle empty output gracefully', () => { - const input = `Command: test -Output: -Error: (none)`; - - const output = extractCommandOutput(input); - // Should return original since output is empty - expect(output).toBe(input); - }); - - it('should extract from regex match when Output: is present', () => { - const input = `Some text before -Output: This is the output -Error: Some error`; - - expect(extractCommandOutput(input)).toBe('This is the output'); - }); - - it('should handle JSON objects in output field', () => { - const input = JSON.stringify({ - output: { key: 'value', nested: { data: 'test' } }, - }); - - const output = extractCommandOutput(input); - expect(output).toContain('"key"'); - expect(output).toContain('"value"'); - }); -}); - -describe('formatValue', () => { - it('should return empty string for null or undefined', () => { - expect(formatValue(null)).toBe(''); - expect(formatValue(undefined)).toBe(''); - }); - - it('should extract output from string using extractCommandOutput', () => { - const input = `Command: test -Output: Hello World -Error: (none)`; - - const output = formatValue(input); - expect(output).toContain('Hello World'); - }); - - it('should handle Error objects', () => { - const error = new Error('Test error message'); - expect(formatValue(error)).toBe('Test error message'); - }); - - it('should handle error-like objects', () => { - const errorObj = { message: 'Custom error', stack: 'stack trace' }; - expect(formatValue(errorObj)).toBe('Custom error'); - }); - - it('should stringify objects', () => { - const obj = { key: 'value', number: 42 }; - const output = formatValue(obj); - expect(output).toContain('"key"'); - expect(output).toContain('"value"'); - expect(output).toContain('42'); - }); - - it('should convert primitives to string', () => { - expect(formatValue(123)).toBe('123'); - expect(formatValue(true)).toBe('true'); - expect(formatValue(false)).toBe('false'); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/utils/utils.ts b/packages/vscode-ide-companion/src/webview/utils/utils.ts deleted file mode 100644 index 793f89f07ec..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Shared utility functions for tool call components - * Now re-exports from @qwen-code/webui for backward compatibility - */ - -export { - extractCommandOutput, - formatValue, - safeTitle, - shouldShowToolCall, - groupContent, - hasToolCallOutput, - mapToolStatusToContainerStatus, -} from '@qwen-code/webui'; - -// Re-export types for backward compatibility -export type { - ToolCallContent, - GroupedContent, - ToolCallData, - ToolCallStatus, -} from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js deleted file mode 100644 index f220c40ac73..00000000000 --- a/packages/vscode-ide-companion/tailwind.config.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-env node */ -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); - -/** @type {import('tailwindcss').Config} */ -export default { - // Use webui preset for shared theme configuration - presets: [require('@qwen-code/webui/tailwind.preset')], - content: [ - './src/webview/**/**/*.{js,jsx,ts,tsx}', - // Include webui components to prevent Tailwind JIT from tree-shaking their classes - // Use relative path for pnpm workspace - node_modules symlinks are in root - '../webui/src/**/*.{js,jsx,ts,tsx}', - '../webui/dist/**/*.js', - ], - theme: { - extend: { - keyframes: { - // CompletionMenu mount animation: fade in + slight upward slide - 'completion-menu-enter': { - '0%': { opacity: '0', transform: 'translateY(4px)' }, - '100%': { opacity: '1', transform: 'translateY(0)' }, - }, - // Pulse animation for in-progress tool calls - 'pulse-slow': { - '0%, 100%': { opacity: '1' }, - '50%': { opacity: '0.5' }, - }, - // PermissionDrawer enter animation: slide up from bottom - 'slide-up': { - '0%': { transform: 'translateY(100%)', opacity: '0' }, - '100%': { transform: 'translateY(0)', opacity: '1' }, - }, - }, - animation: { - 'completion-menu-enter': 'completion-menu-enter 150ms ease-out both', - 'pulse-slow': 'pulse-slow 1.5s ease-in-out infinite', - 'slide-up': 'slide-up 200ms ease-out both', - }, - colors: { - qwen: { - orange: '#615fff', - 'clay-orange': '#4f46e5', - ivory: '#f5f5ff', - slate: '#141420', - green: '#6bcf7f', - // Status colors used by toolcall components - success: '#74c991', - error: '#c74e39', - warning: '#e1c08d', - loading: 'var(--app-secondary-foreground)', - }, - }, - borderRadius: { - small: '4px', - medium: '6px', - large: '8px', - }, - spacing: { - small: '4px', - medium: '8px', - large: '12px', - xlarge: '16px', - }, - }, - }, - plugins: [], -}; diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 26b3fd81520..0a70277b2d4 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, "target": "ES2022", "lib": ["ES2022", "dom"], "jsx": "react-jsx", diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index ef2912845e7..5c8bc0b648b 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -7,7 +7,6 @@ Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI,可以作为 - React:`^18.0.0 || ^19.0.0` - React DOM:`^18.0.0 || ^19.0.0` -- `@qwen-code/webui`:`>=0.0.1` - `@qwen-code/sdk`:`>=0.1.8` - 浏览器环境需要能访问 Qwen Code daemon serve 的 HTTP 接口。 @@ -114,7 +113,7 @@ npm install @qwen-code/web-shell Peer dependencies 需要同时安装: ```bash -npm install react react-dom @qwen-code/webui @qwen-code/sdk +npm install react react-dom @qwen-code/sdk ``` ## 接入方式 @@ -157,8 +156,8 @@ chat + terminal)。宿主自行提供 Provider,WebShell 只消费 hooks。 import { DaemonWorkspaceProvider, DaemonSessionProvider, -} from '@qwen-code/webui/daemon-react-sdk'; -import { WebShell } from '@qwen-code/web-shell'; + WebShell, +} from '@qwen-code/web-shell'; export function App() { return ( @@ -327,8 +326,7 @@ Chart/Data 控件、无数据提示和错误提示默认跟随 WebShell 语言 ```text @qwen-code/sdk/daemon ← 协议层(SSE, REST, normalizer) -@qwen-code/webui/daemon-react-sdk ← React adapter(Provider, hooks, store) -@qwen-code/web-shell ← 终端 UI 组件 +@qwen-code/web-shell ← React adapter(Provider, hooks, store)+ 终端 UI 组件 ``` - `WebShell` 必须在 `DaemonWorkspaceProvider` 和 `DaemonSessionProvider` 之下使用。 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index bdb0ddabf0e..0884071a0a7 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, createRef, type CSSProperties, type ReactNode } from 'react'; +import { + act, + createRef, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { DaemonHttpError, @@ -544,7 +550,7 @@ const { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => { const ownerGuard = { capture: () => { const ownerVersion = testState.ownerVersion; @@ -25318,3 +25324,97 @@ describe('fileUploadEnabled customization plumbing', () => { expect(composer?.hasAttribute('data-file-upload-directory')).toBe(false); }); }); + +describe('App connection error reporting (#10406)', () => { + it('reports a persistent connection error once even while host re-renders pass a fresh inline onError', async () => { + // Daemon unreachable: connection.error persists. A host may store each + // reported error in its own state, which re-renders the host and hands + // App an onError with a fresh identity (inline or otherwise). Before the + // fix, every new callback identity re-fired the notification effect for + // the same persistent error — an infinite re-render loop. + mockConnection.error = 'daemon unreachable'; + const calls: string[] = []; + const HOST_NOTICE_CAP = 5; + + function Host() { + const [noticeCount, setNoticeCount] = useState(0); + return ( + { + calls.push(error.message); + if (noticeCount < HOST_NOTICE_CAP) { + setNoticeCount((count) => count + 1); + } + }} + /> + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await flush(); + await act(async () => { + root.unmount(); + }); + container.remove(); + + expect(calls).toEqual(['daemon unreachable']); + }); + + it('still reports when the connection error changes to a different value', async () => { + mockConnection.error = 'daemon unreachable'; + const calls: string[] = []; + const { rerender } = renderApp({ + onError: (error) => calls.push(error.message), + }); + await flush(); + expect(calls).toEqual(['daemon unreachable']); + + mockConnection.error = 'session missing'; + rerender({ onError: (error) => calls.push(error.message) }); + await flush(); + + expect(calls).toEqual(['daemon unreachable', 'session missing']); + }); + + it('reports a recurring error again after the connection recovers', async () => { + mockConnection.error = 'daemon unreachable'; + const calls: string[] = []; + const onError = (error: Error) => calls.push(error.message); + const { rerender } = renderApp({ onError }); + await flush(); + expect(calls).toEqual(['daemon unreachable']); + + mockConnection.error = undefined; + rerender({ onError }); + await flush(); + + mockConnection.error = 'daemon unreachable'; + rerender({ onError }); + await flush(); + + expect(calls).toEqual(['daemon unreachable', 'daemon unreachable']); + }); + + it('delivers a persistent error once when the host attaches onError after it appears', async () => { + // onError is optional: a host may mount while a connection error is + // already active and only attach its handler on a later render. The + // pending error must still be delivered exactly once. + mockConnection.error = 'daemon unreachable'; + const calls: string[] = []; + const { rerender } = renderApp({}); + await flush(); + expect(calls).toEqual([]); + + rerender({ onError: (error) => calls.push(error.message) }); + await flush(); + + expect(calls).toEqual(['daemon unreachable']); + }); +}); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 91544805efe..ec389a9aeb9 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -33,7 +33,7 @@ import { type DaemonSessionNotice, type DaemonSessionOwnerSnapshot, type DaemonStreamingState, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { DaemonHttpError, isDaemonTurnError, @@ -63,6 +63,7 @@ import { SESSION_MONITOR_TOOL_CORRELATION_FEATURE, SESSION_SIDE_TASK_FEATURE, SESSION_TRANSCRIPT_PAGINATION_FEATURE, + WEB_SHELL_SESSION_SOURCE_TYPE, WEB_SHELL_SIDE_TASK_SOURCE_TYPE, } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; @@ -123,6 +124,7 @@ import { ResumeDialog } from './components/dialogs/ResumeDialog'; import { DialogShell } from './components/dialogs/DialogShell'; import { ModelDialog, + type ModelDialogModel, type ModelDialogMode, } from './components/dialogs/ModelDialog'; import { ModelFallbacksDialog } from './components/dialogs/ModelFallbacksDialog'; @@ -285,7 +287,12 @@ import { } from './utils/composerInputState'; import { isDefinitelyRejectedPromptAdmission } from './utils/promptAdmission'; import { base64ToBlob } from './utils/base64'; -import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; +import type { + ACPToolCall, + CommandInfo, + Message, + PermissionRequest, +} from './adapters/types'; import { backgroundShellTaskId, isBackgroundSubAgentToolCall, @@ -972,12 +979,19 @@ export type WebShellSlashCommandHandler = ( ) => boolean | void; export interface WebShellProps { + /** Host-specific label for the Ask User Question free-text choice. */ + askUserFreeTextLabel?: string; /** Called whenever the attached daemon session or workspace changes. */ onSessionIdChange?: ( sessionId: string | undefined, workspaceId?: string, workspaceCwd?: string, ) => void; + /** Called when the active session id or display name changes. */ + onSessionInfoChange?: (session: { + sessionId?: string; + sessionName?: string; + }) => void; /** Called after a new session is created. Session setup waits up to 30 seconds. */ onSessionCreated?: (sessionId: string) => Promise | void; /** Visual theme for the embedded shell. */ @@ -1019,6 +1033,12 @@ export interface WebShellProps { * a turn output such as review changes, an artifact, or a scheduled task. */ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void; + /** Override file-review links without replacing the other right panels. */ + onFileReviewOpen?: ( + request: Extract, + ) => void; + /** Open a completed Insight report in a host-native surface. */ + onInsightReportOpen?: (path: string) => void; /** * Controls which turn output cards appear below messages. Defaults to all. */ @@ -1027,6 +1047,35 @@ export interface WebShellProps { shellRef?: React.Ref; /** Built-in composer toolbar actions to show. Defaults to all actions. */ composerToolbarActions?: readonly ComposerToolbarAction[]; + /** Optionally filter main-model entries without changing shared defaults. */ + mainModelFilter?: (model: ModelDialogModel) => boolean; + /** Stack completion details vertically for narrow embedded hosts. */ + compactComposerOverlays?: boolean; + /** Submit slash items marked as immediate actions when selected. */ + autoSubmitSlashCommands?: boolean; + /** Host-only slash entries or presentation overrides. */ + additionalSlashCommands?: readonly CommandInfo[]; + /** Keep Context Usage available while restored-session usage is loading. */ + contextUsageAlwaysVisible?: boolean; + /** Let the host expose the last user turn's edit-and-resend action. */ + userMessageEditing?: boolean; + /** + * Called before WebShell starts its built-in user-message edit flow. Return + * true when the host owns the edit-and-resend lifecycle. + */ + onUserMessageEditRequest?: ( + turnIndex: number, + content: string, + ) => boolean | void; + /** Cycle the approval mode when an otherwise-unhandled Tab is pressed. */ + cycleModeOnTab?: boolean; + /** + * Creator attribution recorded on sessions this shell creates, and the + * source filter embedded hosts use to list only their own sessions. Defaults + * to the browser Web Shell's `'default'`; the VS Code companion overrides it + * so its sessions stay distinguishable from CLI and browser ones. + */ + sessionSourceType?: string; /** Built-in actions appended to the context-sensitive default toolbar. */ composerToolbarAdditionalActions?: readonly ComposerToolbarAction[]; /** @@ -1049,7 +1098,12 @@ export interface WebShellProps { * at most once per animation frame during active generation. */ onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void; - /** Called when a critical error occurs (auth failure, session gone, etc). */ + /** + * Called when a critical error occurs (auth failure, session gone, etc). + * Each distinct connection error value is reported once; reporting resets + * when the connection recovers. Replacing the handler while an error + * persists does not re-deliver that error. + */ onError?: (error: Error) => void; /** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */ onBugReport?: (info: BugReportInfo) => void; @@ -1210,6 +1264,7 @@ type PendingReasoningIntent = { }; const emptyComposerApi: WebShellComposerApi = { + focus: () => {}, insertText: () => {}, setText: () => {}, addTags: () => {}, @@ -1219,6 +1274,7 @@ const emptyComposerApi: WebShellComposerApi = { }; const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = []; +const EMPTY_ADDITIONAL_SLASH_COMMANDS: readonly CommandInfo[] = []; const DEFAULT_CHAT_MAX_WIDTH = 1000; const DEFAULT_CHAT_HEADER_ITEMS: readonly WebShellChatHeaderItem[] = [ 'title', @@ -1958,7 +2014,9 @@ function readScopedModelSetting( } export function App({ + askUserFreeTextLabel, onSessionIdChange, + onSessionInfoChange, onSessionCreated, theme: providedTheme, onThemeChange, @@ -2006,9 +2064,20 @@ export function App({ onSplitSessionIdsChange, renderPaneHeaderActions, onRightPanelOpen, + onFileReviewOpen, + onInsightReportOpen, messageTurnOutputs, shellRef, composerToolbarActions, + mainModelFilter, + compactComposerOverlays = false, + autoSubmitSlashCommands = false, + additionalSlashCommands = EMPTY_ADDITIONAL_SLASH_COMMANDS, + contextUsageAlwaysVisible = false, + userMessageEditing = false, + onUserMessageEditRequest, + cycleModeOnTab = false, + sessionSourceType = WEB_SHELL_SESSION_SOURCE_TYPE, composerToolbarAdditionalActions, composerPlaceholders, compactThinking = false, @@ -2218,6 +2287,7 @@ export function App({ ]); const customization = useMemo( () => ({ + askUserFreeTextLabel, composerTagIcons, builtinAtProviders, atProviders, @@ -2245,6 +2315,7 @@ export function App({ fileUploadDirectory, }), [ + askUserFreeTextLabel, composerTagIcons, builtinAtProviders, atProviders, @@ -3866,6 +3937,10 @@ export function App({ ); const handleTurnOutputOpen = useCallback( (request: TurnOutputOpenRequest) => { + if (request.kind === 'review' && onFileReviewOpen) { + onFileReviewOpen(request); + return; + } if (onRightPanelOpen) { onRightPanelOpen(request); return; @@ -3963,6 +4038,7 @@ export function App({ }, [ getDefaultReviewPanelWidth, + onFileReviewOpen, onRightPanelOpen, openReviewPanel, openScheduledTaskPanel, @@ -6009,9 +6085,17 @@ export function App({ return false; }, [pushToast, t]); const sessionDisplayName = connection.displayName ?? sessionStatusDisplayName; + useEffect(() => { + onSessionInfoChange?.({ + sessionId: connection.sessionId, + sessionName: sessionDisplayName, + }); + }, [connection.sessionId, onSessionInfoChange, sessionDisplayName]); const [currentMode, setCurrentMode] = useState('default'); const currentModeRef = useRef(currentMode); currentModeRef.current = currentMode; + const sessionSourceTypeRef = useRef(sessionSourceType); + sessionSourceTypeRef.current = sessionSourceType; const setPendingMode = useCallback((modeId: string) => { currentModeRef.current = modeId; setCurrentMode(modeId); @@ -6158,6 +6242,7 @@ export function App({ gitModeIntentRef.current.mode === 'branch' ? { name: gitModeIntentRef.current.name } : undefined, + sessionSourceType: sessionSourceTypeRef.current, onSessionCreated: onSessionCreatedRef.current, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; @@ -8138,11 +8223,21 @@ export function App({ onConnectionChange?.(connection.status); }, [connection.status, onConnectionChange]); + // Report each distinct connection.error value only once. Hosts may pass an + // inline onError (or one whose identity changes) and update their own state + // when it fires; the resulting re-render then hands this effect a fresh + // callback identity, which would re-notify the same persistent error + // forever (#10406). + const lastReportedConnectionErrorRef = useRef(undefined); useEffect(() => { - if (connection.error) { - const error = new Error(connection.error); - onError?.(error); + if (!connection.error) { + lastReportedConnectionErrorRef.current = undefined; + return; } + if (lastReportedConnectionErrorRef.current === connection.error) return; + if (!onError) return; + lastReportedConnectionErrorRef.current = connection.error; + onError(new Error(connection.error)); }, [connection.error, onError]); useLayoutEffect(() => { @@ -8382,6 +8477,9 @@ export function App({ ]); const handleCycleMode = useCallback(() => { + // findIndex, not indexOf: narrowing currentMode to the tuple member type + // silently degrades when the SDK's declaration bundle leaves its + // permission-mode import dangling, and the build must survive both states. const idx = MODES_CYCLE.findIndex((mode) => mode === currentMode); const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length]; handleSetMode(next); @@ -9435,6 +9533,35 @@ export function App({ [sessionActions], ); + const editUserMessage = useCallback( + async (turnIndex: number, content: string) => { + if (onUserMessageEditRequest?.(turnIndex, content) === true) return; + + const restoreComposer = () => { + editorRef.current?.setText(content); + editorRef.current?.focus(); + }; + + restoreComposer(); + window.setTimeout(restoreComposer, 0); + try { + const { snapshots } = await sessionActions.getRewindSnapshots(); + const snapshot = snapshots.find( + (entry) => entry.turnIndex === turnIndex, + ); + if (!snapshot) throw new Error(t('rewind.empty')); + await sessionActions.rewindSession(snapshot.promptId, { + rewindFiles: false, + }); + } catch (error) { + reportError(error, t('rewind.failed', { reason: String(error) })); + } finally { + restoreComposer(); + } + }, + [onUserMessageEditRequest, reportError, sessionActions, t], + ); + const handleRewindError = useCallback( (error: unknown) => { if (isAlreadyDispatched(error)) return; @@ -11664,6 +11791,7 @@ export function App({ retainedCommands, refreshedSkillCommands, getLocalCommands(t, { sideTaskAvailable: sideTasksAvailable }), + [...additionalSlashCommands], ), t, ) @@ -11680,6 +11808,7 @@ export function App({ }; }); }, [ + additionalSlashCommands, connection.commands, connection.skills, hiddenCommands, @@ -12092,6 +12221,9 @@ export function App({ + void editUserMessage( + turnIndex, + content, + ) + : undefined + } onBranchSession={handleBranchCurrentSession} bottomOverlayInset={bottomPanelInset} welcomeHeader={ @@ -13383,6 +13524,7 @@ export function App({ onTurnOutputOpen={handleTurnOutputOpen} onImagePreview={openImagePanel} onAttachmentPreview={openAttachmentPanel} + onInsightReportOpen={onInsightReportOpen} onReviewChanges={openReviewPanel} onOpenArtifact={openArtifactPanel} onOpenScheduledTask={openScheduledTaskPanel} @@ -13543,6 +13685,7 @@ export function App({ onError={reportError} variant="floating" keyboardActive={askUserOverlayVisible} + customInputLabel={askUserFreeTextLabel} />
)} @@ -13709,6 +13852,7 @@ export function App({ )} fileName.endsWith('.js')) + .map((fileName) => readFileSync(resolve(DIST_DIR, fileName), 'utf8')) + .join('\n'); +} + function readInjectedCss(): string { const match = readBundle().match( /^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");/, @@ -29,23 +37,16 @@ function enclosingLayer(rule: Rule): string | undefined { } describe('build artifact — package boundary', () => { - it('externalizes @qwen-code/webui/daemon-react-sdk', () => { - const bundle = readBundle(); - expect(bundle).toContain('from "@qwen-code/webui/daemon-react-sdk"'); + it('does not depend on @qwen-code/webui', () => { + const bundle = readPackageJavascript(); + expect(bundle).not.toContain('@qwen-code/webui'); }); - it('does not inline DaemonSessionProvider source code', () => { - const bundle = readBundle(); - expect(bundle).not.toMatch(/DaemonStoreContext\s*=\s*createContext/); - }); - - it('does not inline createContext from React for provider contexts', () => { - const bundle = readBundle(); - const contextMatches = bundle.match(/createContext\(/g) ?? []; - // WebShell's own ThemeContext is fine; but there should be at most - // a small number of createContext calls (WebShell internal only). - // If webui Provider got bundled, we'd see many more. - expect(contextMatches.length).toBeLessThanOrEqual(3); + it('owns the DaemonSessionProvider source code', () => { + const bundle = readPackageJavascript(); + expect(bundle).toContain( + 'useDaemonSessionNotices must be used within DaemonSessionProvider', + ); }); it('externalizes react and react-dom', () => { @@ -58,7 +59,7 @@ describe('build artifact — package boundary', () => { }); it('externalizes @qwen-code/sdk subpaths', () => { - const bundle = readBundle(); + const bundle = readPackageJavascript(); // Should not contain raw SDK implementation expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/); }); diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts index 9b620ab6138..66825ba9dc5 100644 --- a/packages/web-shell/client/completions/slashCompletion.test.ts +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -174,6 +174,7 @@ describe('getSlashCommandCompletionResult', () => { detail: 'Review current code', apply: '/skills review ', type: 'skill', + autoSubmit: true, }, ]); }); diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index 586b82aa3bb..701d36aaffb 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -29,9 +29,11 @@ export interface SlashCommandCompletionItem { label: string; apply: string; detail?: string; + argumentHint?: string; category?: CommandDisplayCategory; section?: string; type?: 'command-info' | 'skill'; + autoSubmit?: boolean; } export interface SlashCommandCompletionResult { @@ -48,6 +50,7 @@ const COMMAND_NAME_PATTERN = String.raw`([^\s/]+)`; interface SubcommandNode { name: string; description: string; + argumentHint?: string; children?: SubcommandNode[]; } @@ -75,11 +78,19 @@ const SUBCOMMAND_TREE_ZH: Record = { { name: 'zh-CN', description: '中文' }, ], }, - { name: 'output', description: '设置 LLM 输出语言' }, + { + name: 'output', + description: '设置 LLM 输出语言', + argumentHint: '<语言>', + }, ], extensions: [ { name: 'manage', description: '管理扩展' }, - { name: 'install', description: '安装扩展' }, + { + name: 'install', + description: '安装扩展', + argumentHint: '<来源>', + }, ], }; @@ -107,11 +118,19 @@ const SUBCOMMAND_TREE_EN: Record = { { name: 'zh-CN', description: '中文' }, ], }, - { name: 'output', description: 'Set LLM output language' }, + { + name: 'output', + description: 'Set LLM output language', + argumentHint: '', + }, ], extensions: [ { name: 'manage', description: 'Manage installed extensions' }, - { name: 'install', description: 'Install an extension from a source' }, + { + name: 'install', + description: 'Install an extension from a source', + argumentHint: '', + }, ], }; @@ -169,12 +188,14 @@ function resolveSubcommands( dynamicSkills: SkillInfo[] | undefined, language: WebShellLanguage, commandSubcommands?: string[], + commandArgumentHint?: string, ): SubcommandNode[] | null { if (cmdName === 'skills' && parts.length === 0) { if (!dynamicSkills || dynamicSkills.length === 0) return null; return dynamicSkills.map((s) => ({ name: s.name, description: s.description, + ...(s.argumentHint ? { argumentHint: s.argumentHint } : {}), })); } @@ -190,10 +211,14 @@ function resolveSubcommands( } if (!nodes && parts.length === 0 && commandSubcommands?.length) { - nodes = commandSubcommands.map((name) => ({ - name, - description: '', - })); + nodes = commandSubcommands.map((name) => { + const argumentHint = getSubcommandArgumentHint(commandArgumentHint, name); + return { + name, + description: '', + ...(argumentHint ? { argumentHint } : {}), + }; + }); } if (!nodes) return null; @@ -206,6 +231,25 @@ function resolveSubcommands( return nodes; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Preserve an argument-bearing suffix from a server command's hint. */ +function getSubcommandArgumentHint( + argumentHint: string | undefined, + name: string, +): string | undefined { + if (!argumentHint) return undefined; + const match = argumentHint.match( + new RegExp( + `(?:^|\\||\\[)\\s*${escapeRegExp(name)}(?:\\s+([^|\\]]+))?`, + ), + ); + const suffix = match?.[1]?.trim(); + return suffix || undefined; +} + function comparePrefixFirst(a: string, b: string, query: string): number { const aLower = a.toLowerCase(); const bLower = b.toLowerCase(); @@ -221,11 +265,25 @@ function compareSlashCommands( query: string, categoryOrder: CommandDisplayCategoryOrder, ): number { + const priority = (a.completionPriority ?? 0) - (b.completionPriority ?? 0); + if (priority !== 0) return priority; const order = compareCommandsByCategory(a, b, categoryOrder); if (order !== 0) return order; return query ? comparePrefixFirst(a.name, b.name, query) : 0; } +function hasSubcommandPicker( + command: CommandInfo, + language: WebShellLanguage, +): boolean { + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + return ( + command.name === 'skills' || + Boolean(command.subcommands?.length) || + Boolean(tree[command.name]) + ); +} + const COMMAND_SECTION_KEYS: Record = { custom: 'slash.category.custom', skill: 'slash.category.skill', @@ -296,13 +354,42 @@ export function getSlashCommandArgumentHint( commands: CommandInfo[], language: WebShellLanguage, ): string | null { - const match = text.match(new RegExp(`^/${COMMAND_NAME_PATTERN}(\\s*)$`)); + const match = text.match( + new RegExp(`^/${COMMAND_NAME_PATTERN}(?:\\s+(.*?))?\\s*$`), + ); if (!match) return null; const cmdName = match[1]; const cmd = commands.find((c) => c.name === cmdName); if (!cmd) return null; + const subcommandPath = match[2]?.trim(); + if (subcommandPath) { + const parts = subcommandPath.split(/\s+/); + if (cmdName === 'skills' && parts.length === 1) { + return ( + commands.find((command) => command.name === parts[0])?.argumentHint ?? + null + ); + } + + let nodes = resolveSubcommands( + cmdName, + [], + [], + language, + cmd.subcommands, + cmd.argumentHint, + ); + let selected: SubcommandNode | undefined; + for (const part of parts) { + selected = nodes?.find((node) => node.name === part); + if (!selected) return null; + nodes = selected.children ?? null; + } + return selected?.argumentHint?.trim() || null; + } + const argumentHint = cmd.argumentHint?.trim(); if (argumentHint) return argumentHint; @@ -444,6 +531,7 @@ export function getSlashCommandCompletionResult( skills, language, cmd?.subcommands, + cmd?.argumentHint, ); if (!nodes) return null; @@ -465,8 +553,14 @@ export function getSlashCommandCompletionResult( id: command, label: node.name, detail: node.description || undefined, + ...(node.argumentHint + ? { argumentHint: node.argumentHint } + : {}), apply: `${command} `, ...(isSkillList ? { type: 'skill' as const } : {}), + ...(!node.children?.length && !node.argumentHint + ? { autoSubmit: true } + : {}), }; }); @@ -502,19 +596,30 @@ export function getSlashCommandCompletionResult( const showCommandInfo = category === 'custom' || category === 'skill'; return { id: command.name, - label: `/${command.name}`, + label: command.completionLabel || `/${command.name}`, detail: command.description || undefined, + ...(!command.autoSubmit && command.argumentHint + ? { argumentHint: command.argumentHint } + : {}), apply, category, // Section headers only make sense while browsing the category-ordered // list; a relevance-ranked result set interleaves categories, so headers // would appear before nearly every row. Drop them during search. ...(isBrowsing - ? { section: translate(COMMAND_SECTION_KEYS[category]) } + ? { + section: + command.completionSection || + translate(COMMAND_SECTION_KEYS[category]), + } : {}), ...(showCommandInfo && command.description ? { type: 'command-info' as const } : {}), + ...(command.autoSubmit && + !hasSubcommandPicker(command, language) + ? { autoSubmit: true } + : {}), }; }); @@ -581,6 +686,7 @@ export function slashCompletionSource( getSkills(), language, cmd?.subcommands, + cmd?.argumentHint, ); if (!nodes) return null; @@ -607,7 +713,11 @@ export function slashCompletionSource( } : {}), detail: n.description || undefined, + ...(n.argumentHint ? { argumentHint: n.argumentHint } : {}), apply: `${command} `, + ...(n.children?.length || n.argumentHint + ? {} + : { autoSubmit: true }), }; }); diff --git a/packages/web-shell/client/components/AtMentionPanel.tsx b/packages/web-shell/client/components/AtMentionPanel.tsx index fc63afaee6c..c1cca577e54 100644 --- a/packages/web-shell/client/components/AtMentionPanel.tsx +++ b/packages/web-shell/client/components/AtMentionPanel.tsx @@ -35,6 +35,7 @@ export function AtMentionPanel({ menu, anchorRef, panelRef, + compact, onSelect, onAccept, onBack, @@ -44,6 +45,7 @@ export function AtMentionPanel({ menu: AtMentionMenuState; anchorRef: RefObject; panelRef: RefObject; + compact?: boolean; onSelect: (index: number) => boolean; onAccept: (index?: number) => boolean; onBack: () => boolean; @@ -117,8 +119,11 @@ export function AtMentionPanel({ const maxHeight = Math.max(96, Math.min(300, rect.top - safeTop - 8)); const next = { left: Math.max( - 12, - Math.min(rect.left + 16, window.innerWidth - panelWidth - 12), + compact ? 8 : 12, + Math.min( + rect.left + (compact ? 0 : 16), + window.innerWidth - panelWidth - (compact ? 8 : 12), + ), ), bottom: window.innerHeight - rect.top + 8, width: rect.width, @@ -158,7 +163,7 @@ export function AtMentionPanel({ window.removeEventListener('resize', scheduleUpdatePosition); window.removeEventListener('scroll', scheduleUpdatePosition, true); }; - }, [anchorRef, panelRef]); + }, [anchorRef, compact, panelRef]); const rows = menu.level === 'categories' @@ -223,6 +228,7 @@ export function AtMentionPanel({ ref={panelRef} className={styles.atPanel} data-at-mention-panel="true" + data-web-shell-compact-overlay={compact ? '' : undefined} style={ { ...themeVars, diff --git a/packages/web-shell/client/components/BranchPickerPopover.test.tsx b/packages/web-shell/client/components/BranchPickerPopover.test.tsx index 850a6a02690..bdb0d34cb0f 100644 --- a/packages/web-shell/client/components/BranchPickerPopover.test.tsx +++ b/packages/web-shell/client/components/BranchPickerPopover.test.tsx @@ -61,9 +61,11 @@ const { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/BranchPickerPopover.tsx b/packages/web-shell/client/components/BranchPickerPopover.tsx index 59ebf0fda10..e52856d9457 100644 --- a/packages/web-shell/client/components/BranchPickerPopover.tsx +++ b/packages/web-shell/client/components/BranchPickerPopover.tsx @@ -5,7 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonGitBranchesResult, DaemonGitBranchInfo, diff --git a/packages/web-shell/client/components/ChatContextHeader.test.tsx b/packages/web-shell/client/components/ChatContextHeader.test.tsx index 9a64023ece2..e1239b0514d 100644 --- a/packages/web-shell/client/components/ChatContextHeader.test.tsx +++ b/packages/web-shell/client/components/ChatContextHeader.test.tsx @@ -7,9 +7,11 @@ import { I18nProvider } from '../i18n'; import { ChatContextHeader } from './ChatContextHeader'; // The QR entry reads the workspace connection from context. -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index bf42374aeab..7ccb1f59ac5 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -4,6 +4,13 @@ margin: 0 0 14px; } +.editorShell[data-web-shell-compact-composer] { + width: 100%; + min-width: 0; + box-sizing: border-box; + margin-bottom: 8px; +} + .editorShellDropdownOpen { z-index: 7; } @@ -27,6 +34,12 @@ --chat-send-button-foreground: #fafafa; } +.editorShell[data-web-shell-compact-composer] .container { + width: 100%; + min-width: 0; + box-sizing: border-box; +} + .content { position: relative; z-index: 2; @@ -43,6 +56,10 @@ padding: 12px; } +.editorShell[data-web-shell-compact-composer] .content { + padding: 10px; +} + .container[data-image-drag-active] .content { border-color: var(--primary); background: color-mix( @@ -209,6 +226,17 @@ overflow: hidden; } +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) { + width: min(360px, calc(100vw - 16px)); + max-width: min( + var(--radix-popover-content-available-width), + calc(100vw - 16px) + ); + box-sizing: border-box; + min-width: 0; + padding: 6px; +} + :global([data-web-shell-slash-detail]) { z-index: calc(var(--web-shell-popover-z-index, 1000) + 1); width: min(320px, calc(100vw - 24px)); @@ -222,6 +250,26 @@ overflow: hidden; } +:global( + [data-web-shell-toolbar-popover][data-web-shell-compact-overlay] + ) { + width: min(360px, calc(100vw - 16px)); + max-width: calc(100vw - 16px); + box-sizing: border-box; + padding: 6px; +} + +:global( + [data-web-shell-toolbar-popover][data-web-shell-compact-overlay] + ) + .dropdownItemDesc { + display: -webkit-box; + overflow-wrap: anywhere; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + :global([data-web-shell-reasoning-popover]) { overflow-x: hidden; overflow-y: auto; @@ -331,6 +379,44 @@ column-gap: 0; } +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) + .slashItem { + display: flex; + align-items: stretch; + flex-direction: column; + gap: 1px; + padding: 6px 8px; +} + +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) + .slashDescription { + display: none; + overflow-wrap: anywhere; + text-align: left; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) + .slashItemActive + .slashDescription { + display: -webkit-box; +} + +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) + .slashCommand { + min-width: 0; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +:global([data-web-shell-slash-menu][data-web-shell-compact-overlay]) + .slashSectionCount { + display: none; +} + .slashItem:hover, .slashItemActive { background: var(--accent); @@ -381,6 +467,12 @@ white-space: nowrap; } +.slashArgumentHint { + color: var(--muted-foreground); + font-size: 12px; + font-weight: 400; +} + .slashDescription { min-width: 0; overflow: hidden; @@ -417,6 +509,29 @@ line-height: 1.35; } +.atPanel[data-web-shell-compact-overlay] { + width: min(360px, calc(100vw - 16px)); + padding: 4px; +} + +.atPanel[data-web-shell-compact-overlay] .atItemDescription { + overflow-wrap: anywhere; + white-space: normal; +} + +.atPanel[data-web-shell-compact-overlay] .atItemMain { + grid-template-columns: minmax(0, 1fr); + row-gap: 1px; +} + +.atPanel[data-web-shell-compact-overlay] .atItemSubtitle { + justify-self: start; + overflow-wrap: anywhere; + text-align: left; + text-overflow: clip; + white-space: normal; +} + .atPanelHeaderWrap { flex: 0 0 auto; padding: 3px 4px 7px; @@ -945,6 +1060,10 @@ gap: 8px; } +.editorShell[data-web-shell-compact-composer] .toolbar { + gap: 4px; +} + .toolbarLeading, .toolbarStart, .toolbarEnd, @@ -960,6 +1079,10 @@ min-width: 0; } +.editorShell[data-web-shell-compact-composer] .toolbarLeading { + margin-left: 0; +} + .toolbar[data-mobile-voice-active='true'] .toolbarLeading, .toolbar[data-mobile-voice-active='true'] [data-hide-during-mobile-voice] { display: none; @@ -1193,6 +1316,27 @@ margin-right: -5px; } +.editorShell[data-web-shell-compact-composer] .toolbarRight { + gap: 0; + margin-right: 0; +} + +.editorShell[data-web-shell-compact-composer] + .toolbarRight + button[data-available] { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.editorShell[data-web-shell-compact-composer] + .toolbarRight + button + svg { + width: 16px; + height: 16px; +} + .toolbarRightCustom { display: inline-flex; min-width: 0; diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 4f978c4bcc0..34afd6f774c 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -61,9 +61,11 @@ vi.mock('./BranchPickerPopover', async () => { }); // Mock useWorkspace so BranchPickerPopover can render without a real provider. -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 4ffb3592620..b48cf8fc3f3 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -19,10 +19,10 @@ import { Tooltip as TooltipPrimitive } from 'radix-ui'; import { DAEMON_APPROVAL_MODES, useOptionalWorkspace, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { CommandInfo } from '../adapters/types'; import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; -import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk'; +import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionGroupPresetColor, DaemonWorkspaceGitStatus, @@ -30,7 +30,7 @@ import type { import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; import type { SkillInfo } from '../completions/slashCompletion'; import { useI18n } from '../i18n'; -import type { DaemonReasoningControls } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonReasoningControls } from '@qwen-code/web-shell/daemon-react-sdk'; import { useWebShellPortalRoot } from '../portalRoot'; import { useWebShellCustomization, @@ -85,6 +85,7 @@ import { ChevronRightIcon, FolderClosedIcon, LoaderCircleIcon, + SlashIcon, UploadIcon, XIcon, } from 'lucide-react'; @@ -160,6 +161,7 @@ function collectDroppedFiles(dataTransfer: DataTransfer): File[] { const ACTIVE_TOOLBAR_ACTIONS = [ 'approvalMode', + 'commands', 'contextUsage', 'gitBranch', 'model', @@ -182,6 +184,7 @@ interface ChatEditorProps { onInputTextChange?: (text: string) => void; onAttachmentsChange?: (hasAttachments: boolean) => void; onCycleMode?: () => void; + cycleModeOnTab?: boolean; onToggleShortcuts?: () => void; onCancel?: () => void; isRunning?: boolean; @@ -193,6 +196,7 @@ interface ChatEditorProps { commands: CommandInfo[]; skills?: SkillInfo[]; slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + autoSubmitSlashCommands?: boolean; queuedMessages?: string[]; onPopQueuedMessages?: () => boolean; onClearQueuedMessages?: () => boolean; @@ -230,6 +234,8 @@ interface ChatEditorProps { /** Current context-window occupancy for the `contextUsage` toolbar ring. */ tokenCount?: number; contextWindow?: number; + /** Keep Context Usage available before a restored session reports usage. */ + contextUsageAlwaysVisible?: boolean; /** Show the context-usage breakdown, exactly like typing /context. */ onShowContextUsage?: () => void; availableModels?: Array<{ id: string; label?: string }>; @@ -272,6 +278,7 @@ interface ChatEditorProps { /** Click a pasted image in the composer to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; + compactOverlays?: boolean; } const CHAT_EDITOR_THEME = { @@ -797,6 +804,7 @@ function ToolbarPopover({ noResultsLabel, header, submenu, + compact = false, }: { open: boolean; items: DropdownItem[]; @@ -815,6 +823,7 @@ function ToolbarPopover({ triggerAriaLabel: string; sectionLabel: string; }; + compact?: boolean; }) { const [searchQuery, setSearchQuery] = useState(''); const [submenuOpen, setSubmenuOpen] = useState(false); @@ -963,6 +972,7 @@ function ToolbarPopover({ collisionPadding={8} collisionBoundary={collisionBoundary ?? undefined} data-web-shell-toolbar-popover + data-web-shell-compact-overlay={compact ? '' : undefined} data-web-shell-reasoning-popover={submenu ? '' : undefined} onClick={(event) => event.stopPropagation()} onOpenAutoFocus={(event) => { @@ -1131,6 +1141,7 @@ function SlashCommandPanel({ anchorRef, panelRef, detailRef, + compact, onClose, onSelect, onAccept, @@ -1139,6 +1150,7 @@ function SlashCommandPanel({ anchorRef: RefObject; panelRef: RefObject; detailRef: RefObject; + compact?: boolean; onClose: () => void; onSelect: (index: number) => boolean; onAccept: (index?: number) => boolean; @@ -1216,14 +1228,15 @@ function SlashCommandPanel({ ref={panelRef} side="top" align="start" - alignOffset={16} - sideOffset={8} - avoidCollisions={false} - collisionPadding={12} + alignOffset={compact ? 0 : 16} + sideOffset={compact ? 6 : 8} + avoidCollisions={compact} + collisionPadding={compact ? 8 : 12} collisionBoundary={collisionBoundary ?? undefined} className="duration-0 data-open:animate-none data-closed:animate-none" role="listbox" data-web-shell-slash-menu + data-web-shell-compact-overlay={compact ? '' : undefined} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} onInteractOutside={(event) => { @@ -1291,7 +1304,7 @@ function SlashCommandPanel({ }`} onMouseEnter={(event) => { onSelect(index); - if (!item.detail) { + if (!item.detail || compact) { setHoverDetail(null); return; } @@ -1329,6 +1342,12 @@ function SlashCommandPanel({ > {item.label} + {item.argumentHint && ( + + {' '} + {item.argumentHint} + + )} {item.detail && ( @@ -1345,7 +1364,7 @@ function SlashCommandPanel({ { if (!open) setHoverDetail(null); }} @@ -1454,6 +1473,7 @@ export const ChatEditor = memo( onInputTextChange, onAttachmentsChange, onCycleMode, + cycleModeOnTab = false, onToggleShortcuts, onCancel, isRunning = false, @@ -1464,6 +1484,7 @@ export const ChatEditor = memo( commands, skills = [], slashCommandCategoryOrder, + autoSubmitSlashCommands = false, queuedMessages = [], onPopQueuedMessages, currentMode = 'default', @@ -1486,6 +1507,7 @@ export const ChatEditor = memo( visibleToolbarActions, tokenCount = 0, contextWindow = 0, + contextUsageAlwaysVisible = false, onShowContextUsage, availableModels = [], onSelectMode, @@ -1520,6 +1542,7 @@ export const ChatEditor = memo( onImageIngestionNotice, onImagePreview, onAttachmentPreview, + compactOverlays = false, } = props; const { @@ -1648,6 +1671,7 @@ export const ChatEditor = memo( onSubmit, onInputTextChange, onCycleMode, + cycleModeOnTab, onToggleShortcuts, disabled, fileDragEnabled: fileUploadEnabled !== false, @@ -1655,6 +1679,7 @@ export const ChatEditor = memo( commands, skills, slashCommandCategoryOrder, + autoSubmitSlashCommands, queuedMessages, onPopQueuedMessages, currentMode, @@ -2106,6 +2131,7 @@ export const ChatEditor = memo( }; const showModeAction = showToolbarAction('approvalMode'); const showModelAction = showToolbarAction('model'); + const showCommandAction = showToolbarAction('commands'); const commandNames = useMemo( () => new Set(commands.map((command) => command.name.replace(/^\/+/, ''))), @@ -2608,6 +2634,7 @@ export const ChatEditor = memo( }`} data-composer data-web-shell-composer + data-web-shell-compact-composer={compactOverlays ? '' : undefined} onDragOver={cancelShellFileDrag} onDrop={cancelShellFileDrag} > @@ -2786,7 +2813,7 @@ export const ChatEditor = memo( )}
)} -
+
{core.pastedImages.length > 0 && (
{core.pastedImages.map((img, i) => { @@ -2972,9 +2999,10 @@ export const ChatEditor = memo( anchorRef={containerRef} panelRef={slashPanelRef} detailRef={slashDetailRef} + compact={compactOverlays} onClose={core.closeSlashMenu} onSelect={core.selectSlashCompletion} - onAccept={core.acceptSlashCompletion} + onAccept={(index) => core.acceptSlashCompletion(index, true)} /> )} {core.atMenu && ( @@ -2982,6 +3010,7 @@ export const ChatEditor = memo( menu={core.atMenu} anchorRef={containerRef} panelRef={atPanelRef} + compact={compactOverlays} onSelect={core.selectAtCompletion} onAccept={core.acceptAtCompletion} onBack={() => { @@ -3160,6 +3189,7 @@ export const ChatEditor = memo( }`} > )} {showToolbarAction('contextUsage') && - contextWindow > 0 && - tokenCount > 0 && ( + (contextUsageAlwaysVisible || + (contextWindow > 0 && tokenCount > 0)) && ( @@ -3371,25 +3402,60 @@ export const ChatEditor = memo( onShowContextUsage?.(); }} disabled={!onShowContextUsage} - aria-label={t('status.contextUsed', { - pct: ((tokenCount / contextWindow) * 100).toFixed( - 1, - ), - })} + aria-label={ + contextWindow > 0 && tokenCount > 0 + ? t('status.contextUsed', { + pct: ( + (tokenCount / contextWindow) * + 100 + ).toFixed(1), + }) + : t('contextUsage.title') + } > 0 + ? (tokenCount / contextWindow) * 100 + : 0 + } /> - {formatContextUsageDetail(tokenCount, contextWindow)} + {contextWindow > 0 && tokenCount > 0 + ? formatContextUsageDetail( + tokenCount, + contextWindow, + ) + : t('contextUsage.title')} )} + {showCommandAction && ( + + )} {showToolbarAction('voice') && ( <> diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 1d591e77f3f..4de7a33b471 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -95,7 +95,7 @@ const latestComposerCoreOptions = vi.hoisted(() => ({ current: null as Record | null, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], useActions: () => daemonActions, useConnection: () => connectionState, diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index d47ee0f5434..a203c6d56a4 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -23,7 +23,7 @@ import { useTranscriptStore, useWorkspace, type DaemonSessionActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { type DaemonSessionArtifact, type DaemonSessionMonitorTaskStatus, @@ -252,7 +252,7 @@ export function ChatPane({ sessionWorkflowEnabled = false, }: ChatPaneProps) { const { t } = useI18n(); - const { renderComposerFooter: CustomComposerFooter } = + const { renderComposerFooter: CustomComposerFooter, askUserFreeTextLabel } = useWebShellCustomization(); const connection = useConnection(); const actions = useActions(); @@ -1381,6 +1381,7 @@ export function ChatPane({ onError={reportError} variant="floating" keyboardActive={false} + customInputLabel={askUserFreeTextLabel} />
)} diff --git a/packages/web-shell/client/components/InsightProgress.module.css b/packages/web-shell/client/components/InsightProgress.module.css index 5a33971bfc2..5f44fa30dc0 100644 --- a/packages/web-shell/client/components/InsightProgress.module.css +++ b/packages/web-shell/client/components/InsightProgress.module.css @@ -57,3 +57,27 @@ color: var(--muted-foreground); font-size: 12px; } + +.pathButton { + appearance: none; + border: 0; + padding: 0; + background: transparent; + font-family: inherit; + font-weight: inherit; + line-height: inherit; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.pathButton:hover { + color: var(--foreground); +} + +.pathButton:focus-visible { + border-radius: 2px; + outline: 1px solid var(--agent-blue-500); + outline-offset: 2px; +} diff --git a/packages/web-shell/client/components/InsightReady.tsx b/packages/web-shell/client/components/InsightReady.tsx index 45b0bd869a1..6f46197859a 100644 --- a/packages/web-shell/client/components/InsightReady.tsx +++ b/packages/web-shell/client/components/InsightReady.tsx @@ -3,15 +3,29 @@ import { useI18n } from '../i18n'; interface InsightReadyProps { path: string; + onInsightReportOpen?: (path: string) => void; } -export function InsightReady({ path }: InsightReadyProps) { +export function InsightReady({ + path, + onInsightReportOpen, +}: InsightReadyProps) { const { t } = useI18n(); return (
{t('insight.ready')} - {path} + {onInsightReportOpen ? ( + + ) : ( + {path} + )}
); } diff --git a/packages/web-shell/client/components/LocalControlQrButton.test.tsx b/packages/web-shell/client/components/LocalControlQrButton.test.tsx index 7e2c0a081ad..56bceff6031 100644 --- a/packages/web-shell/client/components/LocalControlQrButton.test.tsx +++ b/packages/web-shell/client/components/LocalControlQrButton.test.tsx @@ -38,9 +38,11 @@ vi.mock('./ui/popover', async () => { return { Popover, PopoverTrigger, PopoverContent }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/LocalControlQrButton.tsx b/packages/web-shell/client/components/LocalControlQrButton.tsx index c5bacc194e7..af9ff259fe5 100644 --- a/packages/web-shell/client/components/LocalControlQrButton.tsx +++ b/packages/web-shell/client/components/LocalControlQrButton.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import { CopyIcon, QrCodeIcon } from 'lucide-react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../i18n'; import { warnClipboardWriteFailure, diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index bd2d4aa41a5..cc1474fd0ab 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -34,11 +34,13 @@ interface MessageItemProps { /** Click an uploaded image in a user message to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; + onInsightReportOpen?: (path: string) => void; workspaceCwd?: string; showRetryHint?: boolean; onRetryClick?: () => void; sendFailed?: boolean; onRetrySend?: () => void; + onEditUserMessage?: () => void; onBranchSession?: (branchRecordId?: string) => void | Promise; branchRecordId?: string; showAssistantActions?: boolean; @@ -54,11 +56,13 @@ export const MessageItem = memo(function MessageItem({ onShowContextDetail, onImagePreview, onAttachmentPreview, + onInsightReportOpen, workspaceCwd, showRetryHint = false, onRetryClick, sendFailed = false, onRetrySend, + onEditUserMessage, onBranchSession, branchRecordId, showAssistantActions = false, @@ -92,6 +96,7 @@ export const MessageItem = memo(function MessageItem({ isLocateFlashing={isLocateFlashing} sendFailed={sendFailed} onRetrySend={onRetrySend} + onEdit={onEditUserMessage} onImagePreview={onImagePreview} onAttachmentPreview={onAttachmentPreview} /> @@ -178,7 +183,12 @@ export const MessageItem = memo(function MessageItem({ /> ); case 'insight_ready': - return ; + return ( + + ); case 'insight_error': return (
@@ -298,6 +308,8 @@ function areMessageItemPropsEqual( if (prev.onRetryClick !== next.onRetryClick) return false; if (prev.sendFailed !== next.sendFailed) return false; if (prev.onRetrySend !== next.onRetrySend) return false; + if (prev.onEditUserMessage !== next.onEditUserMessage) return false; + if (prev.onInsightReportOpen !== next.onInsightReportOpen) return false; if (prev.onBranchSession !== next.onBranchSession) return false; if (prev.branchRecordId !== next.branchRecordId) return false; if (prev.showAssistantActions !== next.showAssistantActions) return false; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index cc743f1021e..b987f6594f8 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -82,6 +82,8 @@ interface MessageListProps { /** Click an uploaded image in a user message to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; + onInsightReportOpen?: (path: string) => void; + onEditUserMessage?: (targetTurnIndex: number, content: string) => void; loadingTranscript?: boolean; catchingUp?: boolean; hasOlderHistory?: boolean; @@ -2839,6 +2841,8 @@ export const MessageList = memo( onShowContextDetail, onImagePreview, onAttachmentPreview, + onInsightReportOpen, + onEditUserMessage, loadingTranscript, catchingUp, hasOlderHistory = false, @@ -2885,6 +2889,18 @@ export const MessageList = memo( const { t } = useI18n(); const transcriptRenderMode = useTranscriptRenderMode(); const compactMode = useContext(CompactModeContext); + const editableUserTurn = useMemo(() => { + const turnIndexById = new Map(); + let lastId: string | undefined; + let turnIndex = 0; + for (const message of messages) { + if (message.role !== 'user') continue; + turnIndexById.set(message.id, turnIndex); + lastId = message.id; + turnIndex += 1; + } + return { lastId, turnIndexById }; + }, [messages]); // Render-phase caches below are reusable only against this post-commit // identity. An abandoned render cannot advance it, so its cache writes are // rejected by the next committed render. @@ -5405,6 +5421,10 @@ export const MessageList = memo( displayItem.message.role === 'assistant' ? displayItem.message.branchRecordId : undefined; + const editableUserContent = + displayItem.message.role === 'user' + ? displayItem.message.content + : undefined; return ( + onEditUserMessage( + editableUserTurn.turnIndexById.get( + displayItem.message.id, + ) ?? 0, + editableUserContent, + ) + : undefined + } workspaceCwd={workspaceCwd} showRetryHint={showRetryHint} onRetryClick={onRetryClick} @@ -5473,6 +5511,11 @@ export const MessageList = memo( onShowContextDetail, onImagePreview, onAttachmentPreview, + onInsightReportOpen, + onEditUserMessage, + editableUserTurn, + hasOlderHistory, + historyCapacityReached, generateContent, headerOffset, visibleItems, diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index cd752e97c2d..56c4ede4917 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -78,7 +78,7 @@ let workspaceActions: { const sessionsReload = vi.fn(async () => sessionsState.sessions); const statusReload = vi.fn(async () => statusState.report); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => connectionState, useActions: () => workspaceActions, useStatusReport: (options: { autoLoad?: boolean; detail?: string } = {}) => { diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index ee60abb8ad5..86cbbc29963 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -10,7 +10,7 @@ import { useConnection, useStatusReport, useWorkspace, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionGroupPresetColor, DaemonSessionPrInfo, diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index 228bc9ff9b9..4e837cab8f1 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -29,7 +29,7 @@ let workspaceClient: { // which depend on `reload`'s identity, don't re-fire on every render. let reloadMock: ReturnType; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonSessionProvider: (props: any) => (
({ streamingState: 'responding' as string })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useStreamingState: () => mocks.streamingState, useTranscriptBlocks: () => [], })); diff --git a/packages/web-shell/client/components/StreamingStatus.tsx b/packages/web-shell/client/components/StreamingStatus.tsx index 8abe1ed6b62..9076bb1dc78 100644 --- a/packages/web-shell/client/components/StreamingStatus.tsx +++ b/packages/web-shell/client/components/StreamingStatus.tsx @@ -3,7 +3,7 @@ import { PHRASE_CHANGE_INTERVAL_MS, getLoadingPhrases, } from '../constants/loadingPhrases'; -import { useStreamingState } from '@qwen-code/webui/daemon-react-sdk'; +import { useStreamingState } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../i18n'; import { useWebShellCustomization } from '../customization'; import { useStreamingLoadingMetrics } from '../hooks/useStreamingLoadingMetrics'; diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx index 0266bb00110..f29e3462629 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx @@ -29,7 +29,7 @@ const mocks = vi.hoisted(() => ({ appProps: [] as Array>, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonSessionProvider: ({ children, ...props diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx index 26c4b4bea6b..1dce8bb5ae2 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -4,7 +4,7 @@ import { DaemonSessionProvider, useWorkspace, useWorkspaceActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon'; import { App, type WebShellProps } from '../App'; import { diff --git a/packages/web-shell/client/components/agents/AgentCreatePage.tsx b/packages/web-shell/client/components/agents/AgentCreatePage.tsx index 90d17b90478..1c59c1b5f2b 100644 --- a/packages/web-shell/client/components/agents/AgentCreatePage.tsx +++ b/packages/web-shell/client/components/agents/AgentCreatePage.tsx @@ -8,7 +8,7 @@ import { useTools, type DaemonWorkspaceAgentDetail, type DaemonWorkspaceMcpToolStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { Button } from '../ui/button'; import { Badge } from '../ui/badge'; diff --git a/packages/web-shell/client/components/agents/AgentsManagerPage.tsx b/packages/web-shell/client/components/agents/AgentsManagerPage.tsx index 528b610af05..b12ca9a3389 100644 --- a/packages/web-shell/client/components/agents/AgentsManagerPage.tsx +++ b/packages/web-shell/client/components/agents/AgentsManagerPage.tsx @@ -13,7 +13,7 @@ import { DAEMON_APPROVAL_MODES, useAgents, type DaemonWorkspaceAgentDetail, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { canModifyAgent, diff --git a/packages/web-shell/client/components/agents/agent-tool-options.ts b/packages/web-shell/client/components/agents/agent-tool-options.ts index c4b9db9c1ff..2a3a018fbcb 100644 --- a/packages/web-shell/client/components/agents/agent-tool-options.ts +++ b/packages/web-shell/client/components/agents/agent-tool-options.ts @@ -1,7 +1,7 @@ import type { DaemonWorkspaceMcpToolStatus, DaemonWorkspaceToolStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; export function canAddSelection( selection: ReadonlySet, diff --git a/packages/web-shell/client/components/agents/agents-manager-logic.test.ts b/packages/web-shell/client/components/agents/agents-manager-logic.test.ts index d281294de17..8ab1a909bb9 100644 --- a/packages/web-shell/client/components/agents/agents-manager-logic.test.ts +++ b/packages/web-shell/client/components/agents/agents-manager-logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { DaemonWorkspaceAgentSummary } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceAgentSummary } from '@qwen-code/web-shell/daemon-react-sdk'; import { canModifyAgent, filterAgents, diff --git a/packages/web-shell/client/components/agents/agents-manager-logic.ts b/packages/web-shell/client/components/agents/agents-manager-logic.ts index de9d27b0176..633ed9f3c5a 100644 --- a/packages/web-shell/client/components/agents/agents-manager-logic.ts +++ b/packages/web-shell/client/components/agents/agents-manager-logic.ts @@ -1,4 +1,4 @@ -import type { DaemonWorkspaceAgentSummary } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceAgentSummary } from '@qwen-code/web-shell/daemon-react-sdk'; export type AgentLevelFilter = 'all' | DaemonWorkspaceAgentSummary['level']; diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx index adf86bb02bc..f76d0a56317 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx @@ -10,7 +10,7 @@ import type { import type { DaemonScheduledTask, DaemonSessionActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { TOAST_REQUEST_EVENT, type ToastRequestDetail } from '../ToastHost'; import type { ArtifactWorkspaceTarget } from './useArtifactWorkspaceTarget'; @@ -76,7 +76,7 @@ const { }); vi.mock( - '@qwen-code/webui/daemon-react-sdk', + '@qwen-code/web-shell/daemon-react-sdk', async (importOriginal: () => Promise>) => ({ ...(await importOriginal()), useActions: () => mockActions, diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 335100359ae..9943f9fc431 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -8,7 +8,7 @@ import type { WebShellRightPanelItem } from '../../customization'; import { type DaemonSessionActions, type DaemonScheduledTask, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { EditorState } from '@codemirror/state'; import { basicSetup, EditorView } from 'codemirror'; import { DownloadIcon } from 'lucide-react'; diff --git a/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.test.tsx b/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.test.tsx index ba040caecdb..25b996685a4 100644 --- a/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.test.tsx +++ b/packages/web-shell/client/components/artifacts/CodeReviewArtifactDetail.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceActions } from '@qwen-code/web-shell/daemon-react-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { I18nProvider } from '../../i18n'; import { CodeReviewArtifactDetail } from './CodeReviewArtifactDetail'; diff --git a/packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx b/packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx index 7cd54c2c2b4..4e1f22f0232 100644 --- a/packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx @@ -59,7 +59,7 @@ const { }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonSessionProvider: (props: { children: ReactNode; [key: string]: unknown; diff --git a/packages/web-shell/client/components/artifacts/SideTaskPanel.tsx b/packages/web-shell/client/components/artifacts/SideTaskPanel.tsx index 30e6a504ff4..ab8dfb98fa6 100644 --- a/packages/web-shell/client/components/artifacts/SideTaskPanel.tsx +++ b/packages/web-shell/client/components/artifacts/SideTaskPanel.tsx @@ -6,7 +6,7 @@ import { useTranscriptBlocks, useTranscriptHistory, useWorkspace, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { WEB_SHELL_HISTORY_PAGE_SIZE, WEB_SHELL_MAX_TRANSCRIPT_BLOCKS, diff --git a/packages/web-shell/client/components/artifacts/SubagentDetail.integration.test.tsx b/packages/web-shell/client/components/artifacts/SubagentDetail.integration.test.tsx index 0af6de5a968..f2417f62999 100644 --- a/packages/web-shell/client/components/artifacts/SubagentDetail.integration.test.tsx +++ b/packages/web-shell/client/components/artifacts/SubagentDetail.integration.test.tsx @@ -51,7 +51,7 @@ const { ] as Message[], })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonSessionProvider: ({ children }: { children: ReactNode }) => children, useConnection: () => connection, useWorkspace: () => ({ client: workspaceClient }), diff --git a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx index 5a1dc7dcc37..4cf8c2d2306 100644 --- a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx +++ b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx @@ -3,7 +3,7 @@ import { DaemonSessionProvider, useConnection, useWorkspace, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, Message } from '../../adapters/types'; import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from '../../constants/sessions'; diff --git a/packages/web-shell/client/components/artifacts/TokenUsagePanel.test.tsx b/packages/web-shell/client/components/artifacts/TokenUsagePanel.test.tsx index c02c6a184f4..97c47580d3c 100644 --- a/packages/web-shell/client/components/artifacts/TokenUsagePanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/TokenUsagePanel.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import type { DaemonSessionActions, DaemonSessionStatsStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { TokenUsagePanel } from './TokenUsagePanel'; diff --git a/packages/web-shell/client/components/artifacts/TokenUsagePanel.tsx b/packages/web-shell/client/components/artifacts/TokenUsagePanel.tsx index c8e9108d6cf..c040bb17c02 100644 --- a/packages/web-shell/client/components/artifacts/TokenUsagePanel.tsx +++ b/packages/web-shell/client/components/artifacts/TokenUsagePanel.tsx @@ -5,7 +5,7 @@ import type { DaemonSessionStatsModelMetrics, DaemonSessionStatsSource, DaemonSessionStatsStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { isSessionDisconnectedError } from '../../utils/sessionErrors'; import { formatDuration } from '../messages/StatsMessage'; diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx b/packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx index dbe43be4621..860c6a8978e 100644 --- a/packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx +++ b/packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx @@ -21,7 +21,7 @@ const { workspaceByCwd: vi.fn(), })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspaceActions: () => ({ readFileBytes, stat, diff --git a/packages/web-shell/client/components/artifacts/artifactUtils.ts b/packages/web-shell/client/components/artifacts/artifactUtils.ts index 9b3ef1bdd6d..bda56a3ca38 100644 --- a/packages/web-shell/client/components/artifacts/artifactUtils.ts +++ b/packages/web-shell/client/components/artifacts/artifactUtils.ts @@ -2,7 +2,7 @@ import type { DaemonSessionArtifact, DaemonWorkspaceFileBytes, } from '@qwen-code/sdk/daemon'; -import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceActions } from '@qwen-code/web-shell/daemon-react-sdk'; export function artifactKindLabel( kind: string, diff --git a/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts b/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts index 900bc7fa8e6..393f630e3d3 100644 --- a/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts +++ b/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts @@ -7,7 +7,7 @@ import { useWorkspaceActions, type DaemonFileStat, type DaemonWorkspaceActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useEffect, useMemo, useRef } from 'react'; export type ArtifactWorkspaceActions = Pick< diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index a7ba909a8ed..828ad7a37ad 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -88,7 +88,7 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useChannels: (options: unknown) => { useChannelsMock(options); return channelState.current; diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index b94f1a17cff..faf4347f3e9 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -30,7 +30,10 @@ import type { DaemonChannelUpsertRequest, DaemonWorkspaceCapability, } from '@qwen-code/sdk/daemon'; -import { useChannels, useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { + useChannels, + useWorkspace, +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { extractErrorDetail } from '../../utils/errorDetail'; import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx index 863cc3b8f2f..a25b576dae0 100644 --- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx @@ -10,7 +10,7 @@ if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DAEMON_APPROVAL_MODES: ['plan', 'default', 'yolo'], })); diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx index 82d6d0b17e6..189dc3e3da2 100644 --- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; +import { DAEMON_APPROVAL_MODES } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; import { dp } from './dialogStyles'; diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx index b77e85ad1a0..c2df1768388 100644 --- a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonMetricsSeriesBucket } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonMetricsSeriesBucket } from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -196,7 +196,7 @@ let fullState: HookState = { }; const seenDetails: Array = []; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useStatusReport: (options: { detail?: string } = {}) => { seenDetails.push(options.detail); if (options.detail === 'full') { diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx index 5d9fbca1f4d..b72808788a6 100644 --- a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx @@ -12,7 +12,7 @@ import { type DaemonStatusReport, type DaemonStatusReportLevel, type DaemonStatusReportSection, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { ErrorBoundary } from '../ErrorBoundary'; import { SvgLineChart, type ChartSeries } from './SvgLineChart'; diff --git a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx index 053d37f6be5..c8fdc886afc 100644 --- a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx @@ -41,7 +41,7 @@ const deleteSessionsMock = vi.fn(); let scopedSessionsOptions: unknown; const initialSessions = sessions.slice(); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => ({ sessionId: 'me' }), })); diff --git a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx index e2332d12091..c6e56d59264 100644 --- a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx +++ b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { dp } from './dialogStyles'; import { sessionMatchesGitQuery } from '../sidebar/sessionSearch'; -import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; +import { useConnection } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; import { useFilterInput } from '../../hooks/useFilterInput'; diff --git a/packages/web-shell/client/components/dialogs/GitDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitDialog.test.tsx index 9077089aab1..5a19de63a89 100644 --- a/packages/web-shell/client/components/dialogs/GitDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/GitDialog.test.tsx @@ -77,9 +77,11 @@ const { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/dialogs/GitDialog.tsx b/packages/web-shell/client/components/dialogs/GitDialog.tsx index fb941eb89e0..54fd10ee6db 100644 --- a/packages/web-shell/client/components/dialogs/GitDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GitDialog.tsx @@ -11,7 +11,7 @@ import { useState, type KeyboardEvent, } from 'react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonWorkspaceGitDiffFile } from '@qwen-code/sdk/daemon'; import { ChevronDownIcon, diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx index c501e623961..a4af1220cbe 100644 --- a/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.test.tsx @@ -39,7 +39,7 @@ const { workspaceGitDiff, workspaceGitDiffFile, workspaceClient, shikiState } = }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => ({ client: workspaceClient }), })); diff --git a/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx index 0477781c3c9..1eb1beb92b3 100644 --- a/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GitDiffDialog.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonDiffHunk, DaemonWorkspaceGitDiff, diff --git a/packages/web-shell/client/components/dialogs/GitHubPrsDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitHubPrsDialog.test.tsx index 586d3d86397..0f7a285c9e0 100644 --- a/packages/web-shell/client/components/dialogs/GitHubPrsDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/GitHubPrsDialog.test.tsx @@ -29,7 +29,7 @@ const { workspaceGitHubPullRequests, workspaceClient } = vi.hoisted(() => { return { workspaceGitHubPullRequests, workspaceClient }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => ({ client: workspaceClient }), })); diff --git a/packages/web-shell/client/components/dialogs/GitHubPrsDialog.tsx b/packages/web-shell/client/components/dialogs/GitHubPrsDialog.tsx index 9f0bdf0be05..b0cb4754e25 100644 --- a/packages/web-shell/client/components/dialogs/GitHubPrsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GitHubPrsDialog.tsx @@ -12,7 +12,7 @@ import { GitPullRequestDraftIcon, XIcon, } from 'lucide-react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonGitHubPullRequest, DaemonGitHubPullRequestChecks, diff --git a/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx b/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx index 3c0a11f4beb..2aa2948c7ad 100644 --- a/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/GitLogDialog.test.tsx @@ -28,7 +28,7 @@ const { workspaceGitLog, workspaceGitCommitDetail, workspaceClient } = return { workspaceGitLog, workspaceGitCommitDetail, workspaceClient }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => ({ client: workspaceClient }), })); diff --git a/packages/web-shell/client/components/dialogs/GitLogDialog.tsx b/packages/web-shell/client/components/dialogs/GitLogDialog.tsx index bbb4e6a99d3..443d1c5a725 100644 --- a/packages/web-shell/client/components/dialogs/GitLogDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GitLogDialog.tsx @@ -12,7 +12,7 @@ import { type ReactNode, } from 'react'; import { CheckIcon, CopyIcon } from 'lucide-react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonGitLog, DaemonGitLogEntry, diff --git a/packages/web-shell/client/components/dialogs/GoalsDialog.test.tsx b/packages/web-shell/client/components/dialogs/GoalsDialog.test.tsx index c016a3c8454..141df1ee02e 100644 --- a/packages/web-shell/client/components/dialogs/GoalsDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/GoalsDialog.test.tsx @@ -46,7 +46,7 @@ const { actions } = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspaceActions: () => actions, })); diff --git a/packages/web-shell/client/components/dialogs/GoalsDialog.tsx b/packages/web-shell/client/components/dialogs/GoalsDialog.tsx index 3d52e28a21e..a3394e7866d 100644 --- a/packages/web-shell/client/components/dialogs/GoalsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/GoalsDialog.tsx @@ -10,7 +10,7 @@ import { canResumeGoal } from '../../utils/goalGate'; import { useWorkspaceActions, type DaemonGoal, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { Pause, Pencil, Play, Trash2 } from 'lucide-react'; import { useI18n } from '../../i18n'; import { DialogShell } from './DialogShell'; diff --git a/packages/web-shell/client/components/dialogs/ModelDialog.test.tsx b/packages/web-shell/client/components/dialogs/ModelDialog.test.tsx index 7f1753272ae..281500a7fd1 100644 --- a/packages/web-shell/client/components/dialogs/ModelDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ModelDialog.test.tsx @@ -12,7 +12,7 @@ if (!Element.prototype.scrollIntoView) { } // ModelDialog only reads `useConnection()`; models/current come in via props here. -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => ({}), })); diff --git a/packages/web-shell/client/components/dialogs/ModelDialog.tsx b/packages/web-shell/client/components/dialogs/ModelDialog.tsx index 842ad3fbc5a..7f75360811d 100644 --- a/packages/web-shell/client/components/dialogs/ModelDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ModelDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; +import { useConnection } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; import { dp } from './dialogStyles'; @@ -12,9 +12,10 @@ interface ModelDialogProps { onSelect: (modelId: string) => void; models?: ModelDialogModel[]; currentModelId?: string; + filterModel?: (model: ModelDialogModel) => boolean; } -interface ModelDialogModel { +export interface ModelDialogModel { id: string; baseModelId?: string; label?: string; @@ -96,12 +97,17 @@ export function ModelDialog({ onSelect, models, currentModelId, + filterModel, }: ModelDialogProps) { const connection = useConnection(); const currentModel = currentModelId ?? connection.currentModel ?? ''; const availableModels = useMemo( - () => models ?? ((connection.models ?? []) as ModelDialogModel[]), - [models, connection.models], + () => { + const candidates = + models ?? ((connection.models ?? []) as ModelDialogModel[]); + return filterModel ? candidates.filter(filterModel) : candidates; + }, + [models, connection.models, filterModel], ); const { t } = useI18n(); const listRef = useRef(null); diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx index c211af2598f..f5826167c81 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx @@ -48,7 +48,7 @@ const initialSessions = sessions.slice(); const releaseSessionMock = vi.fn().mockResolvedValue(undefined); let scopedSessionsOptions: unknown; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => ({ sessionId: 'me' }), })); diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx index 6719b69c7a1..520074d0a7a 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx @@ -4,7 +4,7 @@ import { sessionMatchesGitQuery } from '../sidebar/sessionSearch'; import { useConnection, type DaemonSessionSummary, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; import { useFilterInput } from '../../hooks/useFilterInput'; diff --git a/packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx b/packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx index e1113360c16..2add83beabc 100644 --- a/packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx @@ -34,7 +34,7 @@ let sessions = [ const initialSessions = sessions.slice(); let scopedSessionsOptions: unknown; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => ({ sessionId: 'me' }), })); diff --git a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx index 856fb5a2df7..ce526d02009 100644 --- a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { dp } from './dialogStyles'; -import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; +import { useConnection } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; import { useFilterInput } from '../../hooks/useFilterInput'; diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx index 086d43c2c36..a7e5db8cb8a 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx @@ -52,7 +52,7 @@ const { actions } = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspaceActions: () => actions, })); diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx index 4bdbb4b7096..ca6f36ae136 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx @@ -27,7 +27,7 @@ import { useWorkspaceActions, type DaemonScheduledTask, type DaemonScheduledTaskRun, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonExtensionEntry, DaemonWorkspaceCapability, diff --git a/packages/web-shell/client/components/dialogs/SessionRow.test.tsx b/packages/web-shell/client/components/dialogs/SessionRow.test.tsx index 6f3ac95d2c4..d5fa721f4e0 100644 --- a/packages/web-shell/client/components/dialogs/SessionRow.test.tsx +++ b/packages/web-shell/client/components/dialogs/SessionRow.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSessionSummary } from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { dp } from './dialogStyles'; import { SessionRow } from './SessionRow'; diff --git a/packages/web-shell/client/components/dialogs/SessionRow.tsx b/packages/web-shell/client/components/dialogs/SessionRow.tsx index 87596b37de2..999095a4d6e 100644 --- a/packages/web-shell/client/components/dialogs/SessionRow.tsx +++ b/packages/web-shell/client/components/dialogs/SessionRow.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react'; -import { type DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk'; +import { type DaemonSessionSummary } from '@qwen-code/web-shell/daemon-react-sdk'; import { dp } from './dialogStyles'; import { useI18n } from '../../i18n'; import { SessionPrBadge } from '../SessionPrBadge'; diff --git a/packages/web-shell/client/components/dialogs/ToolsDialog.test.tsx b/packages/web-shell/client/components/dialogs/ToolsDialog.test.tsx index 1d18821a224..7d4f99a1f09 100644 --- a/packages/web-shell/client/components/dialogs/ToolsDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ToolsDialog.test.tsx @@ -15,7 +15,7 @@ const tools = [ { name: 'tool-b', displayName: 'Tool B', enabled: false }, ]; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useTools: () => ({ status: { errors: [] }, tools, diff --git a/packages/web-shell/client/components/dialogs/ToolsDialog.tsx b/packages/web-shell/client/components/dialogs/ToolsDialog.tsx index ba91afd649f..23ff98ecbe1 100644 --- a/packages/web-shell/client/components/dialogs/ToolsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ToolsDialog.tsx @@ -3,7 +3,7 @@ import { dp } from './dialogStyles'; import { useTools, type DaemonWorkspaceToolStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useListboxKeyboard } from '../../hooks/useListboxKeyboard'; diff --git a/packages/web-shell/client/components/dialogs/UsageDashboardTab.test.tsx b/packages/web-shell/client/components/dialogs/UsageDashboardTab.test.tsx index 454adc16222..028208529e4 100644 --- a/packages/web-shell/client/components/dialogs/UsageDashboardTab.test.tsx +++ b/packages/web-shell/client/components/dialogs/UsageDashboardTab.test.tsx @@ -24,7 +24,7 @@ let mockState: HookState = { // selected range flows through (which is what drives the refetch). let lastUsageOpts: { range?: string } | undefined; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useUsageDashboard: (opts: { range?: string } = {}) => { lastUsageOpts = opts; return mockState; diff --git a/packages/web-shell/client/components/dialogs/UsageDashboardTab.tsx b/packages/web-shell/client/components/dialogs/UsageDashboardTab.tsx index 32b1643c709..08921547db5 100644 --- a/packages/web-shell/client/components/dialogs/UsageDashboardTab.tsx +++ b/packages/web-shell/client/components/dialogs/UsageDashboardTab.tsx @@ -9,7 +9,7 @@ import { useUsageDashboard, type DaemonUsageRange, type DaemonUsageModelShare, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { formatMegaTokens } from '../../utils/formatTokenCount'; import { TokenHeatmap } from './TokenHeatmap'; diff --git a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx index d23d9002201..20abb4b14db 100644 --- a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx +++ b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx @@ -34,7 +34,7 @@ import { useWorkspace, useWorkspaceActions, useWorkspaceEventSignals, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { trimDialogLabel } from '../../utils/dialogLabels'; import styles from './ExtensionsManagerPage.module.css'; diff --git a/packages/web-shell/client/components/mcp/McpManagerPage.tsx b/packages/web-shell/client/components/mcp/McpManagerPage.tsx index 83d71e43b57..72d7ae90905 100644 --- a/packages/web-shell/client/components/mcp/McpManagerPage.tsx +++ b/packages/web-shell/client/components/mcp/McpManagerPage.tsx @@ -18,8 +18,8 @@ import type { DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, -} from '@qwen-code/webui/daemon-react-sdk'; -import { useMcp, useSettings } from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; +import { useMcp, useSettings } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import { extractErrorDetail } from '../../utils/errorDetail'; diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.tsx b/packages/web-shell/client/components/messages/AskUserQuestion.tsx index 8779a1b66e1..4544030ba2a 100644 --- a/packages/web-shell/client/components/messages/AskUserQuestion.tsx +++ b/packages/web-shell/client/components/messages/AskUserQuestion.tsx @@ -30,6 +30,7 @@ interface AskUserQuestionProps { ) => Promise; onError: (error: unknown, fallback: string) => void; variant?: 'inline' | 'floating'; + customInputLabel?: string; /** * Whether this question should pull keyboard focus to its first option when it * becomes the topmost one. Defaults to true. Split-view panes pass false so an @@ -50,6 +51,7 @@ export function AskUserQuestion({ onError, variant = 'inline', keyboardActive = true, + customInputLabel, }: AskUserQuestionProps) { const submitShortcutLabel = typeof navigator !== 'undefined' && @@ -837,9 +839,13 @@ export function AskUserQuestion({ setCustomInputs({ @@ -882,7 +888,8 @@ export function AskUserQuestion({ > {hasCustomValue ? customInputs[currentIdx] - : t('askUser.typePlaceholder')} + : (customInputLabel ?? + t('askUser.typePlaceholder'))} )}
diff --git a/packages/web-shell/client/components/messages/AuthMessage.tsx b/packages/web-shell/client/components/messages/AuthMessage.tsx index 58733866f6d..f0a8d17f29c 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.tsx +++ b/packages/web-shell/client/components/messages/AuthMessage.tsx @@ -4,7 +4,7 @@ import { type DaemonAuthProviderBaseUrlOption, type DaemonAuthProviderCatalog, type DaemonAuthProviderDescriptor, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import styles from './AuthMessage.module.css'; diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx b/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx index eb3a2f427fd..0c97bfbc08d 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it } from 'vitest'; -import type { DaemonSessionContextUsageStatus } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSessionContextUsageStatus } from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { ContextUsageMessage } from './ContextUsageMessage'; diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.tsx b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx index 151d8beb531..cb58f2fcddd 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.tsx +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx @@ -3,7 +3,7 @@ import type { DaemonContextSkillDetail, DaemonContextToolDetail, DaemonSessionContextUsageStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { getContextUsageLevel } from '../../utils/contextUsage'; import { formatContextTokens as formatTokens } from '../../utils/formatTokenCount'; diff --git a/packages/web-shell/client/components/messages/LocalControlSettingsCard.tsx b/packages/web-shell/client/components/messages/LocalControlSettingsCard.tsx index 58789b9921c..59e33266e14 100644 --- a/packages/web-shell/client/components/messages/LocalControlSettingsCard.tsx +++ b/packages/web-shell/client/components/messages/LocalControlSettingsCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { CopyIcon, WifiIcon } from 'lucide-react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { warnClipboardWriteFailure, diff --git a/packages/web-shell/client/components/messages/McpStatusMessage.tsx b/packages/web-shell/client/components/messages/McpStatusMessage.tsx index 2c2632ddce8..de738ed68c7 100644 --- a/packages/web-shell/client/components/messages/McpStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/McpStatusMessage.tsx @@ -5,8 +5,8 @@ import type { DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, -} from '@qwen-code/webui/daemon-react-sdk'; -import { useMcp } from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; +import { useMcp } from '@qwen-code/web-shell/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; import { extractErrorDetail } from '../../utils/errorDetail'; diff --git a/packages/web-shell/client/components/messages/MemoryMessage.tsx b/packages/web-shell/client/components/messages/MemoryMessage.tsx index 8c450cf4bad..5cc2d430547 100644 --- a/packages/web-shell/client/components/messages/MemoryMessage.tsx +++ b/packages/web-shell/client/components/messages/MemoryMessage.tsx @@ -3,7 +3,7 @@ import { useMemory, type DaemonContextFileScope, type DaemonWorkspaceMemoryFile, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import styles from './MemoryMessage.module.css'; diff --git a/packages/web-shell/client/components/messages/ModelManagementSection.dom.test.tsx b/packages/web-shell/client/components/messages/ModelManagementSection.dom.test.tsx index 9daeab42f29..82407712bb0 100644 --- a/packages/web-shell/client/components/messages/ModelManagementSection.dom.test.tsx +++ b/packages/web-shell/client/components/messages/ModelManagementSection.dom.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonWorkspaceProviderStatus } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceProviderStatus } from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { ModelManagementSection, diff --git a/packages/web-shell/client/components/messages/ModelManagementSection.tsx b/packages/web-shell/client/components/messages/ModelManagementSection.tsx index a90368e98ee..993375392ad 100644 --- a/packages/web-shell/client/components/messages/ModelManagementSection.tsx +++ b/packages/web-shell/client/components/messages/ModelManagementSection.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import type { DaemonWorkspaceProviderModel, DaemonWorkspaceProviderStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import styles from './ModelManagementSection.module.css'; diff --git a/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx b/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx index 5be4d606c94..0bfc1ca5070 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.dom.test.tsx @@ -7,7 +7,7 @@ import type { DaemonSettingUpdateResult, DaemonWorkspaceSettingsStatus, DaemonWorkspaceProviderStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { I18nProvider } from '../../i18n'; import { SettingsMessage, @@ -19,9 +19,11 @@ import type { UseLiveVoiceSetupResult } from '../../live/useLiveVoiceSetup'; // The Daemon category renders LocalControlSettingsCard, which reads the // workspace connection from context; stub it so the category can be // rendered without a DaemonWorkspaceProvider. -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/messages/SettingsMessage.test.ts b/packages/web-shell/client/components/messages/SettingsMessage.test.ts index b4a1fe4be59..cd3a2b0e69d 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.test.ts +++ b/packages/web-shell/client/components/messages/SettingsMessage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { nextSettingIdx, type FlatRow } from './SettingsMessage'; -import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSettingDescriptor } from '@qwen-code/web-shell/daemon-react-sdk'; function setting(key: string): DaemonSettingDescriptor { return { diff --git a/packages/web-shell/client/components/messages/SettingsMessage.tsx b/packages/web-shell/client/components/messages/SettingsMessage.tsx index ed9fd7376f4..40bac495a70 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.tsx @@ -20,7 +20,7 @@ import type { DaemonSettingDescriptor, DaemonSettingUpdateResult, DaemonWorkspaceSettingsStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { WEB_SHELL_LANGUAGES, languageLabel, diff --git a/packages/web-shell/client/components/messages/StatsMessage.tsx b/packages/web-shell/client/components/messages/StatsMessage.tsx index fbc01324026..85429f83c9f 100644 --- a/packages/web-shell/client/components/messages/StatsMessage.tsx +++ b/packages/web-shell/client/components/messages/StatsMessage.tsx @@ -2,7 +2,7 @@ import type { DaemonSessionStatsStatus, DaemonSessionStatsModelMetrics, DaemonSessionStatsToolByName, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { localizeToolDisplayName } from './toolFormatting'; import styles from './StatsMessage.module.css'; diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx index 681981f1f19..2fe0e1ee754 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx @@ -18,7 +18,7 @@ const { getTasksMock, cancelTaskMock } = vi.hoisted(() => ({ getTasksMock: vi.fn(), cancelTaskMock: vi.fn(), })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useActions: () => ({ getTasks: getTasksMock, cancelTask: cancelTaskMock, diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx index d99f28d35f1..29fbcff4271 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx @@ -16,7 +16,7 @@ import { import { useActions, type DaemonSessionActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; import { formatRuntime } from '../../utils/formatRuntime'; diff --git a/packages/web-shell/client/components/messages/ToolApproval.tsx b/packages/web-shell/client/components/messages/ToolApproval.tsx index 235819fa96b..b351e05f462 100644 --- a/packages/web-shell/client/components/messages/ToolApproval.tsx +++ b/packages/web-shell/client/components/messages/ToolApproval.tsx @@ -8,7 +8,7 @@ import { useId, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; -import { isAgentTool } from '@qwen-code/webui/daemon-react-sdk'; +import { isAgentTool } from '@qwen-code/web-shell/daemon-react-sdk'; import type { PermissionRequest, TodoItem } from '../../adapters/types'; import { useI18n } from '../../i18n'; import { PlanExecutionView } from './PlanExecutionView'; diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 0e57f1a0618..f6fe593fb4d 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -317,7 +317,10 @@ export function fencedCodeBlock(language: string, code: string): string { function ExpandedEditContent({ tool }: { tool: ACPToolCall }) { const diff = useMemo(() => extractDiff(tool), [tool]); - const text = useMemo(() => extractText(tool) || '', [tool]); + const text = useMemo( + () => (tool.content ? extractText(tool) || '' : ''), + [tool], + ); if (!diff && !text) return null; return (
diff --git a/packages/web-shell/client/components/messages/UserMessage.module.css b/packages/web-shell/client/components/messages/UserMessage.module.css index 5b8d720ba3f..3ab025e5d67 100644 --- a/packages/web-shell/client/components/messages/UserMessage.module.css +++ b/packages/web-shell/client/components/messages/UserMessage.module.css @@ -226,6 +226,42 @@ height: 14px; } +.editButton { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + margin-top: 2px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + opacity: 0; +} + +.chatMessageColumn:hover .editButton, +.editButton:focus-visible { + opacity: 1; +} + +.editButton:hover { + background: var(--vscode-toolbar-hoverBackground, var(--accent)); + color: var(--foreground); +} + +.editButton:focus-visible { + outline: 1px solid var(--vscode-focusBorder, var(--ring)); + outline-offset: -1px; +} + +.editButton svg { + width: 14px; + height: 14px; +} + .chatContent { white-space: pre-wrap; word-break: break-word; diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx index 6e9244bc3a3..851898d8691 100644 --- a/packages/web-shell/client/components/messages/UserMessage.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.tsx @@ -9,7 +9,7 @@ import { useState, type ReactNode, } from 'react'; -import { CalendarClockIcon, RefreshCwIcon } from 'lucide-react'; +import { CalendarClockIcon, PencilIcon, RefreshCwIcon } from 'lucide-react'; import { FileTypeIcon } from '../FileTypeIcon'; import { describeCron } from '../dialogs/scheduledTasksSchedule'; import { @@ -61,6 +61,7 @@ interface UserMessageProps { isLocateFlashing?: boolean; sendFailed?: boolean; onRetrySend?: () => void; + onEdit?: () => void; /** Click an uploaded image to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; @@ -220,6 +221,7 @@ export const UserMessage = memo(function UserMessage({ isLocateFlashing = false, sendFailed = false, onRetrySend, + onEdit, onImagePreview, onAttachmentPreview, }: UserMessageProps) { @@ -339,7 +341,7 @@ export const UserMessage = memo(function UserMessage({ }, [measureOverflow]); return ( -
+
)} + {onEdit && ( + + )}
); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index 228b2382e12..ddc6015f830 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -84,7 +84,7 @@ const refreshSessionCatalogQueries = vi.hoisted(() => vi.fn()); const useSessionCatalogQueries = vi.hoisted(() => vi.fn(() => [])); const loadSession = vi.hoisted(() => vi.fn()); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => connection, useActions: () => ({ renameSession: vi.fn() }), useWorkspace: () => workspace, diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx index df11ebd0656..a774d1121af 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx @@ -74,7 +74,7 @@ const refreshSessionCatalogQueries = vi.hoisted(() => vi.fn()); const useSessionCatalogQueries = vi.hoisted(() => vi.fn(() => [])); const loadSession = vi.hoisted(() => vi.fn()); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => connection, useActions: () => ({ renameSession: vi.fn() }), useWorkspace: () => workspace, diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index b5527812713..b1c632c4a78 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -17,7 +17,7 @@ import { useConnection, useWorkspace, useWorkspaceActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { DaemonHttpError } from '@qwen-code/sdk/daemon'; import type { DaemonSessionGroup, diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 3fc07123926..f7523ad989f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -170,7 +170,7 @@ const { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => connection, useActions: () => sessionActions, useWorkspace: () => workspace, diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index a7205bc5623..20caab04ace 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -47,9 +47,11 @@ const { }); // Mock useWorkspace so BranchPickerPopover can render without a real provider. -vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >(); return { ...actual, useWorkspace: () => ({ diff --git a/packages/web-shell/client/components/skills/SkillsManagerPage.test.tsx b/packages/web-shell/client/components/skills/SkillsManagerPage.test.tsx index ff82e070573..5028347176e 100644 --- a/packages/web-shell/client/components/skills/SkillsManagerPage.test.tsx +++ b/packages/web-shell/client/components/skills/SkillsManagerPage.test.tsx @@ -9,7 +9,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceSkillStatus } from '@qwen-code/web-shell/daemon-react-sdk'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -35,7 +35,7 @@ const { skillsState, workspaceState } = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useSkills: () => skillsState.current, useWorkspace: () => workspaceState.current, })); diff --git a/packages/web-shell/client/components/skills/SkillsManagerPage.tsx b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx index 01f8efa652e..675c8bbdb73 100644 --- a/packages/web-shell/client/components/skills/SkillsManagerPage.tsx +++ b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx @@ -13,7 +13,7 @@ import { useSkills, useWorkspace, type DaemonWorkspaceSkillStatus, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { filterSkills, diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.test.ts b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts index f3111ffc48c..4bc447ef68a 100644 --- a/packages/web-shell/client/components/skills/skills-manager-logic.test.ts +++ b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceSkillStatus } from '@qwen-code/web-shell/daemon-react-sdk'; import { filterSkills, preserveSkillSelection, diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.ts b/packages/web-shell/client/components/skills/skills-manager-logic.ts index 8b020a22578..4d1efa510fc 100644 --- a/packages/web-shell/client/components/skills/skills-manager-logic.ts +++ b/packages/web-shell/client/components/skills/skills-manager-logic.ts @@ -1,4 +1,4 @@ -import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonWorkspaceSkillStatus } from '@qwen-code/web-shell/daemon-react-sdk'; export type SkillLevelFilter = 'all' | DaemonWorkspaceSkillStatus['level']; export type SkillStatusFilter = 'all' | 'enabled' | 'disabled'; diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx index 7608844f1c8..fd0fd85bc01 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx @@ -33,7 +33,7 @@ vi.mock('@xterm/addon-fit', () => ({ })); vi.mock('../../themeContext', () => ({ useTheme: () => 'light' })); vi.mock('../../config/daemon', () => ({ getDaemonToken: () => '' })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => workspace, })); vi.mock('../../i18n', () => ({ diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.tsx index 2853ab89a39..f1aff071b9a 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import '@xterm/xterm/css/xterm.css'; import { useTheme, type WebShellTheme } from '../../themeContext'; import { getDaemonToken } from '../../config/daemon'; diff --git a/packages/web-shell/client/constants/sessions.test.ts b/packages/web-shell/client/constants/sessions.test.ts index 8471e0353e7..1d962f357f4 100644 --- a/packages/web-shell/client/constants/sessions.test.ts +++ b/packages/web-shell/client/constants/sessions.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from 'vitest'; -import { DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from '@qwen-code/webui/daemon-react-sdk'; +import { DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from '@qwen-code/web-shell/daemon-react-sdk'; import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from './sessions'; describe('web-shell session constants', () => { diff --git a/packages/web-shell/client/constants/toolNames.ts b/packages/web-shell/client/constants/toolNames.ts new file mode 100644 index 00000000000..377f70671ce --- /dev/null +++ b/packages/web-shell/client/constants/toolNames.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Canonical wire name of the Agent (sub-agent) tool. */ +export const AGENT_TOOL_NAME = 'agent'; + +/** Whether a tool name identifies the Agent (sub-agent) tool. */ +export function isAgentTool(toolName: string | undefined): boolean { + return toolName === AGENT_TOOL_NAME; +} diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index c48a3a3a79c..491adda1db0 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -15,7 +15,7 @@ import type { DaemonInputAnnotation, GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; -import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonStreamingState } from '@qwen-code/web-shell/daemon-react-sdk'; import type { ACPToolCall } from './adapters/types'; import type { WelcomeHeaderProps } from './components/WelcomeHeader'; import type { WebShellTheme } from './themeContext'; @@ -308,6 +308,7 @@ export interface WebShellComposerInput { text?: string; tags?: readonly WebShellComposerTag[]; tagPlacement?: WebShellComposerTagPlacement; + clearAttachments?: boolean; submit?: boolean; } @@ -373,6 +374,7 @@ export interface WebShellAtProvider { } export interface WebShellComposerApi { + focus?(): void; insertText(text: string, options?: WebShellComposerTextOptions): void; setText(text: string): void; addTags( @@ -507,6 +509,8 @@ export type LoadingPhrasesResolver = ( ) => readonly string[] | undefined | null; export interface WebShellCustomization { + /** Host-specific label for the Ask User Question free-text choice. */ + askUserFreeTextLabel?: string; renderToolHeaderExtra?: ToolHeaderExtraRenderer; renderWelcomeHeader?: WelcomeHeaderRenderer; renderWelcomeFooter?: WelcomeFooterRenderer; diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/web-shell/client/daemon-react-sdk.ts similarity index 99% rename from packages/webui/src/daemon-react-sdk.ts rename to packages/web-shell/client/daemon-react-sdk.ts index 4d1e8a59687..e19fde415b5 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/web-shell/client/daemon-react-sdk.ts @@ -5,7 +5,7 @@ */ /** - * `@qwen-code/webui/daemon-react-sdk` + * Daemon React bindings owned by `@qwen-code/web-shell`. * * React bindings for the Qwen Code daemon process. * Provides context Providers, hooks, types, and constants @@ -18,7 +18,7 @@ * DaemonWorkspaceProvider, * useConnection, * useStreamingState, - * } from '@qwen-code/webui/daemon-react-sdk'; + * } from '@qwen-code/web-shell'; * ``` */ diff --git a/packages/webui/src/daemon/followupSidechannel.ts b/packages/web-shell/client/daemon/followupSidechannel.ts similarity index 100% rename from packages/webui/src/daemon/followupSidechannel.ts rename to packages/web-shell/client/daemon/followupSidechannel.ts diff --git a/packages/webui/src/daemon/index.ts b/packages/web-shell/client/daemon/index.ts similarity index 98% rename from packages/webui/src/daemon/index.ts rename to packages/web-shell/client/daemon/index.ts index c46ac83201f..29b40bc4d52 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/web-shell/client/daemon/index.ts @@ -134,7 +134,7 @@ export { // ── Re-exported SDK types/constants for UI consumers ────────────── // These allow web-shell and other UI packages to depend only on -// @qwen-code/webui without importing @qwen-code/sdk/daemon directly. +// @qwen-code/web-shell without importing @qwen-code/sdk/daemon directly. export { DAEMON_APPROVAL_MODES } from '@qwen-code/sdk/daemon'; export type { DaemonApprovalMode, diff --git a/packages/webui/src/daemon/midTurnInjectedSidechannel.test.ts b/packages/web-shell/client/daemon/midTurnInjectedSidechannel.test.ts similarity index 100% rename from packages/webui/src/daemon/midTurnInjectedSidechannel.test.ts rename to packages/web-shell/client/daemon/midTurnInjectedSidechannel.test.ts diff --git a/packages/webui/src/daemon/midTurnInjectedSidechannel.ts b/packages/web-shell/client/daemon/midTurnInjectedSidechannel.ts similarity index 100% rename from packages/webui/src/daemon/midTurnInjectedSidechannel.ts rename to packages/web-shell/client/daemon/midTurnInjectedSidechannel.ts diff --git a/packages/webui/src/daemon/pendingPromptVersion.test.ts b/packages/web-shell/client/daemon/pendingPromptVersion.test.ts similarity index 100% rename from packages/webui/src/daemon/pendingPromptVersion.test.ts rename to packages/web-shell/client/daemon/pendingPromptVersion.test.ts diff --git a/packages/webui/src/daemon/pendingPromptVersion.ts b/packages/web-shell/client/daemon/pendingPromptVersion.ts similarity index 100% rename from packages/webui/src/daemon/pendingPromptVersion.ts rename to packages/web-shell/client/daemon/pendingPromptVersion.ts diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.subagent.test.ts b/packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts similarity index 100% rename from packages/webui/src/daemon/session/DaemonSessionProvider.subagent.test.ts rename to packages/web-shell/client/daemon/session/DaemonSessionProvider.subagent.test.ts diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx similarity index 100% rename from packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx rename to packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx similarity index 100% rename from packages/webui/src/daemon/session/DaemonSessionProvider.tsx rename to packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts similarity index 100% rename from packages/webui/src/daemon/session/actions.test.ts rename to packages/web-shell/client/daemon/session/actions.test.ts diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts similarity index 100% rename from packages/webui/src/daemon/session/actions.ts rename to packages/web-shell/client/daemon/session/actions.ts diff --git a/packages/webui/src/daemon/session/clientLifecycle.test.ts b/packages/web-shell/client/daemon/session/clientLifecycle.test.ts similarity index 100% rename from packages/webui/src/daemon/session/clientLifecycle.test.ts rename to packages/web-shell/client/daemon/session/clientLifecycle.test.ts diff --git a/packages/webui/src/daemon/session/clientLifecycle.ts b/packages/web-shell/client/daemon/session/clientLifecycle.ts similarity index 100% rename from packages/webui/src/daemon/session/clientLifecycle.ts rename to packages/web-shell/client/daemon/session/clientLifecycle.ts diff --git a/packages/webui/src/daemon/session/httpErrors.test.ts b/packages/web-shell/client/daemon/session/httpErrors.test.ts similarity index 100% rename from packages/webui/src/daemon/session/httpErrors.test.ts rename to packages/web-shell/client/daemon/session/httpErrors.test.ts diff --git a/packages/webui/src/daemon/session/httpErrors.ts b/packages/web-shell/client/daemon/session/httpErrors.ts similarity index 100% rename from packages/webui/src/daemon/session/httpErrors.ts rename to packages/web-shell/client/daemon/session/httpErrors.ts diff --git a/packages/webui/src/daemon/session/index.ts b/packages/web-shell/client/daemon/session/index.ts similarity index 100% rename from packages/webui/src/daemon/session/index.ts rename to packages/web-shell/client/daemon/session/index.ts diff --git a/packages/webui/src/daemon/session/live-journal-repair.test.ts b/packages/web-shell/client/daemon/session/live-journal-repair.test.ts similarity index 100% rename from packages/webui/src/daemon/session/live-journal-repair.test.ts rename to packages/web-shell/client/daemon/session/live-journal-repair.test.ts diff --git a/packages/webui/src/daemon/session/live-journal-repair.ts b/packages/web-shell/client/daemon/session/live-journal-repair.ts similarity index 100% rename from packages/webui/src/daemon/session/live-journal-repair.ts rename to packages/web-shell/client/daemon/session/live-journal-repair.ts diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts similarity index 100% rename from packages/webui/src/daemon/session/mappers.test.ts rename to packages/web-shell/client/daemon/session/mappers.test.ts diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts similarity index 99% rename from packages/webui/src/daemon/session/mappers.ts rename to packages/web-shell/client/daemon/session/mappers.ts index af27bc7e559..ef06dc02dd3 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/web-shell/client/daemon/session/mappers.ts @@ -200,6 +200,7 @@ export function mapSupportedCommands( name: command.name, description: command.description || '', ...(command.input?.hint ? { argumentHint: command.input.hint } : {}), + ...(command.input === null ? { autoSubmit: true } : {}), ...mapCommandMeta(command._meta), raw: command, })); @@ -775,6 +776,7 @@ function mapAvailableCommandsUpdate( ...(daemonCommand.input?.hint ? { argumentHint: daemonCommand.input.hint } : {}), + ...(daemonCommand.input === null ? { autoSubmit: true } : {}), ...mapCommandMeta(daemonCommand._meta), raw: daemonCommand, }, diff --git a/packages/webui/src/daemon/session/promptContent.test.ts b/packages/web-shell/client/daemon/session/promptContent.test.ts similarity index 100% rename from packages/webui/src/daemon/session/promptContent.test.ts rename to packages/web-shell/client/daemon/session/promptContent.test.ts diff --git a/packages/webui/src/daemon/session/promptContent.ts b/packages/web-shell/client/daemon/session/promptContent.ts similarity index 100% rename from packages/webui/src/daemon/session/promptContent.ts rename to packages/web-shell/client/daemon/session/promptContent.ts diff --git a/packages/webui/src/daemon/session/selectors.test.ts b/packages/web-shell/client/daemon/session/selectors.test.ts similarity index 100% rename from packages/webui/src/daemon/session/selectors.test.ts rename to packages/web-shell/client/daemon/session/selectors.test.ts diff --git a/packages/webui/src/daemon/session/selectors.ts b/packages/web-shell/client/daemon/session/selectors.ts similarity index 100% rename from packages/webui/src/daemon/session/selectors.ts rename to packages/web-shell/client/daemon/session/selectors.ts diff --git a/packages/webui/src/daemon/session/session-context.test.ts b/packages/web-shell/client/daemon/session/session-context.test.ts similarity index 100% rename from packages/webui/src/daemon/session/session-context.test.ts rename to packages/web-shell/client/daemon/session/session-context.test.ts diff --git a/packages/webui/src/daemon/session/session-context.ts b/packages/web-shell/client/daemon/session/session-context.ts similarity index 100% rename from packages/webui/src/daemon/session/session-context.ts rename to packages/web-shell/client/daemon/session/session-context.ts diff --git a/packages/webui/src/daemon/session/status.test.ts b/packages/web-shell/client/daemon/session/status.test.ts similarity index 100% rename from packages/webui/src/daemon/session/status.test.ts rename to packages/web-shell/client/daemon/session/status.test.ts diff --git a/packages/webui/src/daemon/session/status.ts b/packages/web-shell/client/daemon/session/status.ts similarity index 100% rename from packages/webui/src/daemon/session/status.ts rename to packages/web-shell/client/daemon/session/status.ts diff --git a/packages/webui/src/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts similarity index 99% rename from packages/webui/src/daemon/session/types.ts rename to packages/web-shell/client/daemon/session/types.ts index 009e29c54f9..9d26beee42f 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -312,6 +312,7 @@ export interface DaemonCommandInfo { name: string; description: string; argumentHint?: string; + autoSubmit?: boolean; source?: string; raw: DaemonAvailableCommand; } diff --git a/packages/webui/src/daemon/timing.ts b/packages/web-shell/client/daemon/timing.ts similarity index 100% rename from packages/webui/src/daemon/timing.ts rename to packages/web-shell/client/daemon/timing.ts diff --git a/packages/webui/src/daemon/useDaemonFollowupSuggestion.ts b/packages/web-shell/client/daemon/useDaemonFollowupSuggestion.ts similarity index 100% rename from packages/webui/src/daemon/useDaemonFollowupSuggestion.ts rename to packages/web-shell/client/daemon/useDaemonFollowupSuggestion.ts diff --git a/packages/webui/src/daemon/useDaemonMidTurnInjected.ts b/packages/web-shell/client/daemon/useDaemonMidTurnInjected.ts similarity index 100% rename from packages/webui/src/daemon/useDaemonMidTurnInjected.ts rename to packages/web-shell/client/daemon/useDaemonMidTurnInjected.ts diff --git a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx rename to packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx diff --git a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.tsx b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.tsx rename to packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.tsx diff --git a/packages/webui/src/daemon/workspace/actions.test.ts b/packages/web-shell/client/daemon/workspace/actions.test.ts similarity index 100% rename from packages/webui/src/daemon/workspace/actions.test.ts rename to packages/web-shell/client/daemon/workspace/actions.test.ts diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts similarity index 100% rename from packages/webui/src/daemon/workspace/actions.ts rename to packages/web-shell/client/daemon/workspace/actions.ts diff --git a/packages/webui/src/daemon/workspace/goals.actions.test.ts b/packages/web-shell/client/daemon/workspace/goals.actions.test.ts similarity index 100% rename from packages/webui/src/daemon/workspace/goals.actions.test.ts rename to packages/web-shell/client/daemon/workspace/goals.actions.test.ts diff --git a/packages/webui/src/daemon/workspace/hooks/index.ts b/packages/web-shell/client/daemon/workspace/hooks/index.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/index.ts rename to packages/web-shell/client/daemon/workspace/hooks/index.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonAgents.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonAgents.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonAgents.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonAgents.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonAuth.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonAuth.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonAuth.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonAuth.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonChannels.test.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonChannels.test.tsx diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonChannels.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonChannels.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonDiagnostics.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonDiagnostics.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonDiagnostics.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonDiagnostics.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonFiles.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonFiles.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonFiles.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonFiles.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonGlob.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonGlob.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonGlob.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonGlob.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonMcp.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonMemory.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMemory.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonMemory.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonMemory.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonProviders.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonProviders.test.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonProviders.test.tsx rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonProviders.test.tsx diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonProviders.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonProviders.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonProviders.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonProviders.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonResource.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonResource.test.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonResource.test.tsx rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonResource.test.tsx diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonResource.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonResource.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonResource.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonResource.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonSessions.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonSessions.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonSessions.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonSessions.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonSettings.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonSettings.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonSettings.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonSettings.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonSkills.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonSkills.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonStatusReport.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonStatusReport.test.tsx similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonStatusReport.test.tsx rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonStatusReport.test.tsx diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonStatusReport.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonStatusReport.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonStatusReport.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonStatusReport.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonTools.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonTools.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonTools.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonTools.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonUsageDashboard.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonUsageDashboard.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useDaemonUsageDashboard.ts rename to packages/web-shell/client/daemon/workspace/hooks/useDaemonUsageDashboard.ts diff --git a/packages/webui/src/daemon/workspace/hooks/useWorkspaceEventReload.ts b/packages/web-shell/client/daemon/workspace/hooks/useWorkspaceEventReload.ts similarity index 100% rename from packages/webui/src/daemon/workspace/hooks/useWorkspaceEventReload.ts rename to packages/web-shell/client/daemon/workspace/hooks/useWorkspaceEventReload.ts diff --git a/packages/webui/src/daemon/workspace/index.ts b/packages/web-shell/client/daemon/workspace/index.ts similarity index 100% rename from packages/webui/src/daemon/workspace/index.ts rename to packages/web-shell/client/daemon/workspace/index.ts diff --git a/packages/webui/src/daemon/workspace/scheduledTasks.actions.test.ts b/packages/web-shell/client/daemon/workspace/scheduledTasks.actions.test.ts similarity index 100% rename from packages/webui/src/daemon/workspace/scheduledTasks.actions.test.ts rename to packages/web-shell/client/daemon/workspace/scheduledTasks.actions.test.ts diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts similarity index 100% rename from packages/webui/src/daemon/workspace/types.ts rename to packages/web-shell/client/daemon/workspace/types.ts diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 617123c6088..9a2aecaa627 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -823,7 +823,12 @@ for (const theme of THEMES) { // per-workspace fetch. Wait for the loaded session's row before capturing // so the async load has settled — otherwise the row list races the // screenshot and the capture differs between runs. - await expect(sidebar.getByText(primarySessionName)).toBeVisible(); + await expect( + sidebar.getByRole('button', { + name: primarySessionName, + exact: true, + }), + ).toBeVisible(); await captureScreenshot(page, `workspace-sidebar-${theme}`); }); diff --git a/packages/web-shell/client/e2e/webui-tool-output-layout-harness.html b/packages/web-shell/client/e2e/webui-tool-output-layout-harness.html deleted file mode 100644 index d617f4ab955..00000000000 --- a/packages/web-shell/client/e2e/webui-tool-output-layout-harness.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - WebUI tool output layout harness - - - -
- - - diff --git a/packages/web-shell/client/e2e/webui-tool-output-layout-harness.tsx b/packages/web-shell/client/e2e/webui-tool-output-layout-harness.tsx deleted file mode 100644 index e080a0ac181..00000000000 --- a/packages/web-shell/client/e2e/webui-tool-output-layout-harness.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { ChatViewer, type ChatMessageData } from '@qwen-code/webui'; - -const params = new URLSearchParams(window.location.search); -const kind = params.get('kind') === 'execute' ? 'execute' : 'bash'; -const output = `${'0123456789'.repeat(82)}__${kind.toUpperCase()}_TAIL__`; - -const message: ChatMessageData = { - uuid: `${kind}-layout-message`, - timestamp: '2026-07-31T00:00:00.000Z', - type: 'tool_call', - toolCall: { - toolCallId: `${kind}-layout-tool-call`, - kind, - title: 'Run layout regression fixture', - status: 'completed', - rawInput: { command: 'printf long-output' }, - content: [ - { - type: 'content', - content: { type: 'text', text: output }, - }, - ], - }, -}; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/packages/web-shell/client/e2e/webui-tool-output-layout.spec.ts b/packages/web-shell/client/e2e/webui-tool-output-layout.spec.ts deleted file mode 100644 index 22e7367342c..00000000000 --- a/packages/web-shell/client/e2e/webui-tool-output-layout.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { expect, test, type Locator } from '@playwright/test'; - -for (const kind of ['bash', 'execute'] as const) { - test(`keeps collapsible ${kind} output inside the message layout @smoke`, async ({ - page, - }) => { - await page.setViewportSize({ width: 800, height: 900 }); - await page.goto(`/e2e/webui-tool-output-layout-harness.html?kind=${kind}`); - - const messages = page.locator('.chat-viewer-messages'); - const output = page.locator(`.${kind}-toolcall-output-subtle`); - const toggle = page.locator('button[aria-expanded]'); - - await expect(messages).toBeVisible(); - await expect(output).toBeVisible(); - await expect(toggle).toBeVisible(); - await expect(toggle).toHaveAttribute('aria-label', 'Expand output'); - - const collapsed = await readOverflowMetrics(messages, output); - expect(collapsed.outputScrollWidth).toBeGreaterThan( - collapsed.outputClientWidth, - ); - expect(collapsed.messagesScrollWidth).toBe(collapsed.messagesClientWidth); - - const messagesBox = await messages.boundingBox(); - const toggleBox = await toggle.boundingBox(); - if (!messagesBox || !toggleBox) { - throw new Error('Expected visible message and toggle bounding boxes.'); - } - expect(toggleBox.x).toBeGreaterThanOrEqual(messagesBox.x); - expect(toggleBox.x + toggleBox.width).toBeLessThanOrEqual( - messagesBox.x + messagesBox.width, - ); - - await toggle.click(); - await expect(toggle).toHaveAttribute('aria-expanded', 'true'); - - const expanded = await readOverflowMetrics(messages, output); - expect(expanded.outputScrollWidth).toBeGreaterThan( - expanded.outputClientWidth, - ); - expect(expanded.messagesScrollWidth).toBe(expanded.messagesClientWidth); - - const reachableScrollLeft = await output.evaluate((element) => { - element.scrollLeft = element.scrollWidth; - return element.scrollLeft; - }); - expect(reachableScrollLeft).toBeGreaterThan(0); - }); -} - -async function readOverflowMetrics(messages: Locator, output: Locator) { - const messagesMetrics = await messages.evaluate((element) => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - })); - const outputMetrics = await output.evaluate((element) => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - })); - - return { - messagesClientWidth: messagesMetrics.clientWidth, - messagesScrollWidth: messagesMetrics.scrollWidth, - outputClientWidth: outputMetrics.clientWidth, - outputScrollWidth: outputMetrics.scrollWidth, - }; -} diff --git a/packages/web-shell/client/hooks/daemonSessionMappers.ts b/packages/web-shell/client/hooks/daemonSessionMappers.ts index 1047ffc84b7..62938ba013a 100644 --- a/packages/web-shell/client/hooks/daemonSessionMappers.ts +++ b/packages/web-shell/client/hooks/daemonSessionMappers.ts @@ -11,6 +11,8 @@ export function mergeCommands(...groups: CommandInfo[][]): CommandInfo[] { ...command, description: command.description || existing.description, argumentHint: command.argumentHint ?? existing.argumentHint, + autoSubmit: command.autoSubmit ?? existing.autoSubmit, + subcommands: command.subcommands ?? existing.subcommands, }); } else { byName.set(command.name, command); diff --git a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx index 778834187ca..a8c72a9f3bf 100644 --- a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx +++ b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.test.tsx @@ -83,7 +83,7 @@ const testConnection = vi.hoisted(() => ({ sessionId: 'session-a' as string | undefined, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useTranscriptStore: () => testStore, useConnection: () => testConnection, })); diff --git a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts index 7763abef1f8..80c22ab7578 100644 --- a/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts +++ b/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts @@ -11,7 +11,7 @@ import type { import { useConnection, useTranscriptStore, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; // Cap transcript re-renders at ~20fps. During streaming every network chunk // notifies the store; each render then runs the O(transcript) normalization diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx index e7bd5d7d68e..2cac84045ab 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx +++ b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx @@ -29,7 +29,7 @@ const sdkMock = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useActions: () => sdkMock.actions, useDaemonSessionOwnerGuard: () => sdkMock.ownerGuard, })); diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts index 33485f39f62..6c17c1c941a 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.ts +++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts @@ -3,7 +3,7 @@ import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; import { useActions, useDaemonSessionOwnerGuard, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage'; import { isSessionDisconnectedError } from '../utils/sessionErrors'; diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 1f25aa2140f..82e5effa305 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -47,7 +47,7 @@ import type { PromptFile, PromptImage } from '../adapters/promptTypes'; import { useOptionalWorkspace, type UseDaemonFollowupSuggestionReturn, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { getImplicitTabCompletion, getMissingSlashPrefixCompletion, @@ -1095,6 +1095,7 @@ export interface UseComposerCoreOptions { ) => boolean | void; onInputTextChange?: (text: string) => void; onCycleMode?: () => void; + cycleModeOnTab?: boolean; onToggleShortcuts?: () => void; disabled?: boolean; /** @@ -1109,6 +1110,7 @@ export interface UseComposerCoreOptions { commands: CommandInfo[]; skills?: SkillInfo[]; slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + autoSubmitSlashCommands?: boolean; queuedMessages?: string[]; onPopQueuedMessages?: () => boolean; onClearQueuedMessages?: () => boolean; @@ -1380,9 +1382,10 @@ export interface UseComposerCoreReturn { onAcceptFollowup: UseDaemonFollowupSuggestionReturn['onAcceptFollowup']; onDismissFollowup: UseDaemonFollowupSuggestionReturn['onDismissFollowup']; slashMenu: SlashMenuState | null; + openSlashMenu: () => boolean; closeSlashMenu: () => void; selectSlashCompletion: (index: number) => boolean; - acceptSlashCompletion: (index?: number) => boolean; + acceptSlashCompletion: (index?: number, submit?: boolean) => boolean; atMenu: AtMentionMenuState | null; closeAtMenu: () => void; selectAtCompletion: (index: number) => boolean; @@ -1400,6 +1403,7 @@ export function useComposerCore( onSubmit, onInputTextChange, onCycleMode, + cycleModeOnTab = false, onToggleShortcuts, disabled = false, fileDragEnabled = true, @@ -1407,6 +1411,7 @@ export function useComposerCore( commands, skills = [], slashCommandCategoryOrder, + autoSubmitSlashCommands = false, queuedMessages = [], onPopQueuedMessages, currentMode = 'default', @@ -1517,6 +1522,8 @@ export function useComposerCore( }, []); const onCycleModeRef = useRef(onCycleMode); onCycleModeRef.current = onCycleMode; + const cycleModeOnTabRef = useRef(cycleModeOnTab); + cycleModeOnTabRef.current = cycleModeOnTab; const onToggleShortcutsRef = useRef(onToggleShortcuts); onToggleShortcutsRef.current = onToggleShortcuts; const disabledRef = useRef(disabled); @@ -2183,6 +2190,46 @@ export function useComposerCore( closeAtMenuState(); }, [clearAutoAtTriggerIfIntact, closeAtMenuState]); + const openSlashMenu = useCallback(() => { + const view = viewRef.current; + if ( + !view || + disabledRef.current || + shellModeRef.current || + historyBrowseActiveRef.current + ) { + return false; + } + closeAtMenu(); + let cursor = view.state.selection.main.head; + const line = view.state.doc.lineAt(cursor); + if (!line.text.startsWith('/')) { + if (view.state.doc.length > 0) return false; + view.dispatch({ + changes: { from: cursor, to: cursor, insert: '/' }, + selection: { anchor: cursor + 1 }, + scrollIntoView: true, + }); + cursor += 1; + } + const result = getSlashCommandCompletionResult( + view.state.doc.toString(), + cursor, + commandsRef.current, + skillsRef.current, + languageRef.current, + tRef.current, + slashCommandCategoryOrderRef.current ?? DEFAULT_COMMAND_CATEGORY_ORDER, + ); + if (!result) return false; + setSlashMenu({ + ...result, + selectedIndex: 0, + }); + view.focus(); + return true; + }, [closeAtMenu, setSlashMenu]); + const closeAtMenuIfOpenFn = atMenu.closeIfOpen; const closeAtMenuIfOpen = useCallback(() => { const result = closeAtMenuIfOpenFn(); @@ -2279,20 +2326,33 @@ export function useComposerCore( [setSlashMenu], ); - const acceptSlashCompletion = useCallback((index?: number) => { - const view = viewRef.current; - const current = slashMenuRef.current; - if (!view || !current) return false; - const item = current.items[index ?? current.selectedIndex]; - if (!item) return false; - view.dispatch({ - changes: { from: current.from, to: current.to, insert: item.apply }, - selection: { anchor: current.from + item.apply.length }, - scrollIntoView: true, - }); - view.focus(); - return true; - }, []); + const acceptSlashCompletion = useCallback( + (index?: number, submit = false) => { + const view = viewRef.current; + const current = slashMenuRef.current; + if (!view || !current) return false; + const item = current.items[index ?? current.selectedIndex]; + if (!item) return false; + const commandReplacesEntireDraft = + current.from === 0 && current.to === view.state.doc.length; + view.dispatch({ + changes: { from: current.from, to: current.to, insert: item.apply }, + selection: { anchor: current.from + item.apply.length }, + scrollIntoView: true, + }); + view.focus(); + if ( + submit && + autoSubmitSlashCommands && + item.autoSubmit && + commandReplacesEntireDraft + ) { + submitTextRef.current(view); + } + return true; + }, + [autoSubmitSlashCommands], + ); // Track whether editor has content for send button state const [hasContent, setHasContent] = useState(false); @@ -2842,9 +2902,30 @@ export function useComposerCore( return true; } if (slashMenuRef.current) { - return acceptSlashCompletion(); + return acceptSlashCompletion(undefined, true); } if (completionStatus(view.state) === 'active') return false; + const text = view.state.doc.toString(); + const subcommandResult = getSlashCommandCompletionResult( + `${text} `, + text.length + 1, + commandsRef.current, + skillsRef.current, + languageRef.current, + (key) => tRef.current(key), + slashCommandCategoryOrderRef.current ?? + DEFAULT_COMMAND_CATEGORY_ORDER, + ); + if ( + /^\/[^\s/]+$/.test(text) && + subcommandResult?.kind === 'subcommand' + ) { + view.dispatch({ + changes: { from: text.length, insert: ' ' }, + selection: { anchor: text.length + 1 }, + }); + return true; + } const followup = followupStateRef.current; const hasInlineTags = hasInlineComposerTags(view); const followupCompletion = hasInlineTags @@ -3023,10 +3104,12 @@ export function useComposerCore( return true; } if (slashMenuRef.current) { - return acceptSlashCompletion(); + if (acceptSlashCompletion()) return true; + if (!cycleModeOnTabRef.current) return false; } if (completionStatus(view.state) === 'active') { - return acceptCompletion(view); + if (acceptCompletion(view)) return true; + if (!cycleModeOnTabRef.current) return false; } const text = view.state.doc.toString(); const implicitResult = getImplicitTabCompletion( @@ -3060,6 +3143,9 @@ export function useComposerCore( }); return true; } + if (cycleModeOnTabRef.current) { + onCycleModeRef.current?.(); + } return true; }, }, @@ -3501,15 +3587,22 @@ export function useComposerCore( [ command.name, command.description ?? '', + command.completionLabel ?? '', + command.completionSection ?? '', command.source ?? '', command.displayCategory ?? '', command.argumentHint ?? '', command.subcommands?.join(',') ?? '', + command.autoSubmit ? '1' : '0', ].join('\u0000'), ) .join('\u0001'), skills - .map((skill) => [skill.name, skill.description].join('\u0000')) + .map((skill) => + [skill.name, skill.description, skill.argumentHint ?? ''].join( + '\u0000', + ), + ) .join('\u0001'), slashCommandCategoryOrder?.join('|') ?? '', ].join('\u0002'); @@ -4047,6 +4140,14 @@ export function useComposerCore( const view = viewRef.current; if (!view && !isTouchComposer) return; + if (input.clearAttachments) { + pastedImagesRef.current = []; + pastedFilesRef.current = []; + restoredInputAnnotationsRef.current = []; + setPastedImages([]); + setPastedFiles([]); + } + const tagPlacement = input.tagPlacement ?? 'top'; if (input.tags !== undefined && tagPlacement === 'top') { setComposerTags([...input.tags]); @@ -4461,6 +4562,7 @@ export function useComposerCore( onDismissFollowup: onDismissFollowup as UseDaemonFollowupSuggestionReturn['onDismissFollowup'], slashMenu, + openSlashMenu, closeSlashMenu, selectSlashCompletion, acceptSlashCompletion, diff --git a/packages/web-shell/client/hooks/useMessages.test.ts b/packages/web-shell/client/hooks/useMessages.test.ts index 015c697f405..0665e7879aa 100644 --- a/packages/web-shell/client/hooks/useMessages.test.ts +++ b/packages/web-shell/client/hooks/useMessages.test.ts @@ -51,7 +51,7 @@ vi.mock('../adapters/transcriptToMessages', async (importOriginal) => { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => hookState.connection, useTranscriptBlocks: () => hookState.blocks, useWorkspace: () => ({ client: hookState.client }), diff --git a/packages/web-shell/client/hooks/useMessages.ts b/packages/web-shell/client/hooks/useMessages.ts index 592f391378b..7b45c80447a 100644 --- a/packages/web-shell/client/hooks/useMessages.ts +++ b/packages/web-shell/client/hooks/useMessages.ts @@ -10,7 +10,7 @@ import { useConnection, useTranscriptBlocks, useWorkspace, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { transcriptBlocksToDaemonMessages } from '../adapters/transcriptToMessages'; import type { Message } from '../adapters/types'; import { diff --git a/packages/web-shell/client/hooks/useNewSessionSuggestion.ts b/packages/web-shell/client/hooks/useNewSessionSuggestion.ts index ecadffa0186..a4eeb4b96e9 100644 --- a/packages/web-shell/client/hooks/useNewSessionSuggestion.ts +++ b/packages/web-shell/client/hooks/useNewSessionSuggestion.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Message } from '../adapters/types'; -import type { DaemonSessionActions } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSessionActions } from '@qwen-code/web-shell/daemon-react-sdk'; const MIN_PROMPT_LENGTH = 12; const MIN_BTW_MESSAGE_COUNT = 2; diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx index ff275e5c300..af64fa76788 100644 --- a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx @@ -24,7 +24,7 @@ let listWorkspaceSessionsPage: ReturnType; // mock would re-fire the load effect on every render (infinite loop). let client: { listWorkspaceSessionsPage: ReturnType }; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => ({ client, capabilities }), })); diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts index c8ea4f800d6..31904f4aa9b 100644 --- a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts @@ -5,7 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; import { SESSION_LIST_PAGE_SIZE, diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx index ea75846301f..d76db5c7076 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DaemonSessionActions } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSessionActions } from '@qwen-code/web-shell/daemon-react-sdk'; import { DaemonHttpError, type DaemonTranscriptStore, @@ -40,7 +40,7 @@ const sdk = vi.hoisted(() => ({ ownerVersion: 0, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ consumePendingPromptEvents: vi.fn(), getPendingPromptEvents: () => sdk.pendingEvents, getPendingPromptVersion: () => 0, diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index 3d4a5366f53..83af0e57a7f 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -12,7 +12,7 @@ import { useQueuedPrompts, type UseQueuedPromptsResult, } from './useQueuedPrompts'; -import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonStreamingState } from '@qwen-code/web-shell/daemon-react-sdk'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -47,10 +47,10 @@ const sdkMock = vi.hoisted(() => { return mock; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', async () => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async () => { const actual = await vi.importActual< - typeof import('@qwen-code/webui/daemon-react-sdk') - >('@qwen-code/webui/daemon-react-sdk'); + typeof import('@qwen-code/web-shell/daemon-react-sdk') + >('@qwen-code/web-shell/daemon-react-sdk'); // useSyncExternalStore needs reference-stable snapshots; a fresh [] per // call loops the store into "Maximum update depth exceeded". The mutable // sdkMock arrays are only swapped wholesale, so their identity is stable diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 5e485602b82..cd573e810c5 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -23,7 +23,7 @@ import { type DaemonSessionActions, type DaemonStreamingState, type DaemonWorkspaceActions, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonInputAnnotation, DaemonMidTurnMessagesResult, diff --git a/packages/web-shell/client/hooks/useScopedSessions.test.tsx b/packages/web-shell/client/hooks/useScopedSessions.test.tsx index 63c32b40214..1bb77ed790a 100644 --- a/packages/web-shell/client/hooks/useScopedSessions.test.tsx +++ b/packages/web-shell/client/hooks/useScopedSessions.test.tsx @@ -22,7 +22,7 @@ const workspaceClient = { listWorkspaceSessionsPage: vi.fn(), }; -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useSessions: () => ({ sessions: primarySessions, loading: false, diff --git a/packages/web-shell/client/hooks/useScopedSessions.ts b/packages/web-shell/client/hooks/useScopedSessions.ts index fff92028fd2..34ae1b6fec8 100644 --- a/packages/web-shell/client/hooks/useScopedSessions.ts +++ b/packages/web-shell/client/hooks/useScopedSessions.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionArchiveState } from '@qwen-code/sdk/daemon'; import { WEB_SHELL_SESSION_SOURCE_TYPE } from '../constants/sessions'; import { diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx index 7a941fe1ab3..b203fc70b8f 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx +++ b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx @@ -38,7 +38,7 @@ const sdkMock = vi.hoisted(() => ({ artifactsVersion: 0, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useActions: () => sdkMock.actions, useConnection: () => sdkMock.connection, usePromptStatus: () => sdkMock.promptStatus, diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.ts b/packages/web-shell/client/hooks/useSessionArtifacts.ts index 6d9d535eff2..7cefd8549a1 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.ts +++ b/packages/web-shell/client/hooks/useSessionArtifacts.ts @@ -5,7 +5,7 @@ import { usePromptStatus, useDaemonSessionOwnerGuard, useWorkspaceEventSignals, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; const SESSION_ARTIFACTS_FEATURE = 'session_artifacts'; diff --git a/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts index 357852e87ba..21e6f393046 100644 --- a/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts +++ b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useStreamingState, useTranscriptBlocks, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; interface LoadingMetrics { estimatedOutputTokens: number; diff --git a/packages/web-shell/client/index.test.tsx b/packages/web-shell/client/index.test.tsx index 51678aefa40..95e5f0dbb83 100644 --- a/packages/web-shell/client/index.test.tsx +++ b/packages/web-shell/client/index.test.tsx @@ -23,7 +23,7 @@ let workspaceCapabilities: { }; const addWorkspace = vi.fn(); const refreshCapabilities = vi.fn(); -vi.mock('@qwen-code/webui/daemon-react-sdk', async () => { +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async () => { const React = await import('react'); return { DaemonWorkspaceProvider: ({ children }: { children: React.ReactNode }) => { diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 870b55ee438..b1caaa0b5cc 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react'; -import { DaemonWorkspaceProvider } from '@qwen-code/webui/daemon-react-sdk'; +import { DaemonWorkspaceProvider } from '@qwen-code/web-shell/daemon-react-sdk'; import { App, type WebShellProps } from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RootErrorFallback } from './components/RootErrorFallback'; @@ -7,6 +7,7 @@ import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider' import { normalizeLanguage, type WebShellLanguage } from './i18n'; export { WebShellTranscript } from './components/WebShellTranscript'; export type { WebShellTranscriptProps } from './components/WebShellTranscript'; +export * from './daemon-react-sdk'; export interface WebShellWithProvidersProps extends WebShellProps { /** Daemon API base URL. Defaults to the browser origin when omitted. */ @@ -70,7 +71,7 @@ function RootBoundary({ /** * Low-level UI component. Requires ancestor `DaemonWorkspaceProvider` and - * `DaemonSessionProvider` from `@qwen-code/webui/daemon-react-sdk`. The consumer + * `DaemonSessionProvider` from `@qwen-code/web-shell`. The consumer * owns those providers, so this boundary covers only what we render (`App`). */ export function WebShell(props: WebShellProps) { diff --git a/packages/web-shell/client/live/useLiveVoice.test.tsx b/packages/web-shell/client/live/useLiveVoice.test.tsx index 0e074260cbf..10732b723ac 100644 --- a/packages/web-shell/client/live/useLiveVoice.test.tsx +++ b/packages/web-shell/client/live/useLiveVoice.test.tsx @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => mocks.workspace, })); diff --git a/packages/web-shell/client/live/useLiveVoice.ts b/packages/web-shell/client/live/useLiveVoice.ts index a11e0349faa..2a753151d8e 100644 --- a/packages/web-shell/client/live/useLiveVoice.ts +++ b/packages/web-shell/client/live/useLiveVoice.ts @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { DaemonLiveMuteUpdate, DaemonLiveStatus } from '@qwen-code/sdk'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; const LIVE_FEATURE = 'realtime_voice'; const POLL_INTERVAL_MS = 1_000; diff --git a/packages/web-shell/client/live/useLiveVoiceSetup.test.tsx b/packages/web-shell/client/live/useLiveVoiceSetup.test.tsx index c593403359e..a95e2e589d2 100644 --- a/packages/web-shell/client/live/useLiveVoiceSetup.test.tsx +++ b/packages/web-shell/client/live/useLiveVoiceSetup.test.tsx @@ -40,7 +40,7 @@ const mocks = vi.hoisted(() => { return { client, workspace: { client } }; }); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspace: () => mocks.workspace, })); diff --git a/packages/web-shell/client/live/useLiveVoiceSetup.ts b/packages/web-shell/client/live/useLiveVoiceSetup.ts index a93cf5271f6..f955c9c1c41 100644 --- a/packages/web-shell/client/live/useLiveVoiceSetup.ts +++ b/packages/web-shell/client/live/useLiveVoiceSetup.ts @@ -9,7 +9,7 @@ import type { DaemonLiveSetupStatus, DaemonLiveSetupUpdate, } from '@qwen-code/sdk'; -import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; const POLL_INTERVAL_MS = 1_000; diff --git a/packages/web-shell/client/main.test.tsx b/packages/web-shell/client/main.test.tsx index 4c2b82b96df..c5e98bc391e 100644 --- a/packages/web-shell/client/main.test.tsx +++ b/packages/web-shell/client/main.test.tsx @@ -19,7 +19,7 @@ vi.mock('react-dom/client', async (importOriginal) => ({ ...(await importOriginal()), default: { createRoot: () => ({ render: vi.fn() }) }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonWorkspaceProvider: ({ children }: { children: ReactNode }) => children, })); vi.mock('./components/WorkspaceSessionProvider', () => ({ diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index eaa3d7e06c7..9d2c4c9ae69 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { useCallback, useEffect, useState } from 'react'; -import { DaemonWorkspaceProvider } from '@qwen-code/webui/daemon-react-sdk'; +import { DaemonWorkspaceProvider } from '@qwen-code/web-shell/daemon-react-sdk'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RootErrorFallback } from './components/RootErrorFallback'; import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider'; diff --git a/packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx b/packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx index 304956f4609..b534b8d9a27 100644 --- a/packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx +++ b/packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx @@ -52,7 +52,7 @@ const mocks = vi.hoisted(() => ({ })), })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useSessions: mocks.useSessions, useWorkspace: () => mocks.workspace, })); diff --git a/packages/web-shell/client/session-catalog/session-catalog-hooks.ts b/packages/web-shell/client/session-catalog/session-catalog-hooks.ts index 9b69fbe44f3..7328d683da4 100644 --- a/packages/web-shell/client/session-catalog/session-catalog-hooks.ts +++ b/packages/web-shell/client/session-catalog/session-catalog-hooks.ts @@ -6,7 +6,10 @@ import { useState, useSyncExternalStore, } from 'react'; -import { useSessions, useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { + useSessions, + useWorkspace, +} from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonClient, DaemonSessionArchiveState, diff --git a/packages/web-shell/client/utils/sessionPreparation.test.ts b/packages/web-shell/client/utils/sessionPreparation.test.ts index 49dd71ead12..d28be2f91ca 100644 --- a/packages/web-shell/client/utils/sessionPreparation.test.ts +++ b/packages/web-shell/client/utils/sessionPreparation.test.ts @@ -340,6 +340,18 @@ describe('createAndAttachSessionForPrompt', () => { }); }); + it('records the host source type so embedded channels stay attributable', async () => { + const actions = createActions(); + await prepareSession({ + sessionActions: actions, + sessionSourceType: 'vscode', + }); + expect(actions.createSession).toHaveBeenCalledWith({ + workspaceCwd: undefined, + sourceType: 'vscode', + }); + }); + it('forwards branch to createSession and returns the created branch', async () => { const actions = createActions({ createSession: vi.fn(async () => ({ diff --git a/packages/web-shell/client/utils/sessionPreparation.ts b/packages/web-shell/client/utils/sessionPreparation.ts index f5921429cf0..3a5d7e1d991 100644 --- a/packages/web-shell/client/utils/sessionPreparation.ts +++ b/packages/web-shell/client/utils/sessionPreparation.ts @@ -1,7 +1,7 @@ import { DAEMON_APPROVAL_MODES, type DaemonApprovalMode, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { WEB_SHELL_SESSION_SOURCE_TYPE } from '../constants/sessions'; const SESSION_CREATED_CALLBACK_TIMEOUT_MS = 30_000; @@ -37,6 +37,7 @@ export async function createAndAttachSessionForPrompt({ workspaceCwd, worktree, branch, + sessionSourceType = WEB_SHELL_SESSION_SOURCE_TYPE, onSessionCreated, onSessionAllocated, getCurrentSessionId, @@ -49,6 +50,11 @@ export async function createAndAttachSessionForPrompt({ workspaceCwd?: string; worktree?: { slug?: string }; branch?: { name: string }; + /** + * Creator attribution recorded on the session. Embedded hosts pass their own + * value so their sessions stay distinguishable from browser Web Shell ones. + */ + sessionSourceType?: string; onSessionCreated?: (sessionId: string) => Promise | void; onSessionAllocated?: (sessionId: string) => void; getCurrentSessionId: () => string | undefined; @@ -71,7 +77,7 @@ export async function createAndAttachSessionForPrompt({ branch: branchInfo, } = await sessionActions.createSession({ workspaceCwd, - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + sourceType: sessionSourceType, ...(approvalMode ? { approvalMode } : {}), ...(worktree ? { worktree } : {}), ...(branch ? { branch } : {}), diff --git a/packages/web-shell/client/utils/slash-command-action.ts b/packages/web-shell/client/utils/slash-command-action.ts index a4ae227b8b7..7ff14f338f5 100644 --- a/packages/web-shell/client/utils/slash-command-action.ts +++ b/packages/web-shell/client/utils/slash-command-action.ts @@ -1,6 +1,6 @@ import type { WebShellSlashCommandHandler } from '../App'; -export const SLASH_COMMAND_PATTERN = /^\/([\w-]+)(?=\s|$)/; +export const SLASH_COMMAND_PATTERN = /^\/([^\s/]+)(?=\s|$)/; export function invokeSlashCommandHandler( input: string, diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index 2a8653d0907..3357c902c31 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -46,7 +46,7 @@ const mocks = vi.hoisted(() => ({ }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useConnection: () => mocks.connection, useWorkspace: () => mocks.workspace, useWorkspaceEventSignals: () => ({ diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 5de04cdfc92..72dcf24db35 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -11,7 +11,7 @@ import { useConnection, useWorkspace, useWorkspaceEventSignals, -} from '@qwen-code/webui/daemon-react-sdk'; +} from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../i18n'; import { useVoiceCapture } from './useVoiceCapture'; import { diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index da0fbbc4a6e..6870c5e9050 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -9,10 +9,14 @@ ".": { "types": "./dist/types/index.d.ts", "import": "./dist/index.js" + }, + "./daemon-react-sdk": { + "types": "./dist/types/daemon-react-sdk.d.ts", + "import": "./dist/daemon-react-sdk.js" } }, "files": [ - "dist/index.js", + "dist/*.js", "dist/types" ], "scripts": { @@ -67,14 +71,12 @@ }, "peerDependencies": { "@qwen-code/sdk": ">=0.1.8", - "@qwen-code/webui": ">=0.0.1", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@playwright/test": "^1.57.0", "@qwen-code/sdk": "file:../sdk-typescript", - "@qwen-code/webui": "file:../webui", "@tailwindcss/vite": "^4.3.2", "@types/node": "^22.0.0", "@types/react": "^19.2.0", diff --git a/packages/web-shell/tsconfig.json b/packages/web-shell/tsconfig.json index 221bbbbca9c..47e708bec77 100644 --- a/packages/web-shell/tsconfig.json +++ b/packages/web-shell/tsconfig.json @@ -6,6 +6,7 @@ "jsx": "react-jsx", "baseUrl": ".", "paths": { + "@qwen-code/web-shell/daemon-react-sdk": ["./client/daemon-react-sdk.ts"], "@/*": ["./client/*"] }, "strict": true, diff --git a/packages/web-shell/vite.config.ts b/packages/web-shell/vite.config.ts index f4fe0ac5614..8911c9bd34b 100644 --- a/packages/web-shell/vite.config.ts +++ b/packages/web-shell/vite.config.ts @@ -51,14 +51,13 @@ export default defineConfig(({ command }) => ({ plugins: [react(), tailwindcss()], resolve: { alias: { + '@qwen-code/web-shell/daemon-react-sdk': resolve( + __dirname, + './client/daemon-react-sdk.ts', + ), '@': resolve(__dirname, './client'), ...(command === 'serve' ? { - '@qwen-code/webui/daemon-react-sdk': resolve( - __dirname, - '../webui/src/daemon-react-sdk.ts', - ), - '@qwen-code/webui': resolve(__dirname, '../webui/src/index.ts'), '@qwen-code/sdk/daemon': resolve( __dirname, '../sdk-typescript/src/daemon/index.ts', @@ -70,7 +69,7 @@ export default defineConfig(({ command }) => ({ } : {}), }, - dedupe: ['react', 'react-dom', '@qwen-code/webui', '@qwen-code/sdk'], + dedupe: ['react', 'react-dom', '@qwen-code/sdk'], }, build: { outDir: '../dist', diff --git a/packages/web-shell/vite.lib.config.ts b/packages/web-shell/vite.lib.config.ts index bc37dbfc254..77abb39fb14 100644 --- a/packages/web-shell/vite.lib.config.ts +++ b/packages/web-shell/vite.lib.config.ts @@ -108,10 +108,7 @@ function injectCssModules(): Plugin { const escapedCss = JSON.stringify(css); for (const item of Object.values(bundle)) { if (item.type !== 'chunk') continue; - if ( - !item.isEntry && - !item.facadeModuleId?.endsWith('/client/index.tsx') - ) { + if (!item.facadeModuleId?.endsWith('/client/index.tsx')) { continue; } item.code = @@ -128,6 +125,10 @@ export default defineConfig({ plugins: [react(), tailwindcss(), injectCssModules()], resolve: { alias: { + '@qwen-code/web-shell/daemon-react-sdk': resolve( + __dirname, + './client/daemon-react-sdk.ts', + ), '@': resolve(__dirname, './client'), }, }, @@ -137,9 +138,12 @@ export default defineConfig({ build: { emptyOutDir: false, lib: { - entry: 'client/index.tsx', + entry: { + index: 'client/index.tsx', + 'daemon-react-sdk': 'client/daemon-react-sdk.ts', + }, formats: ['es'], - fileName: () => 'index.js', + fileName: (_format, entryName) => `${entryName}.js`, }, rollupOptions: { external: [ @@ -156,8 +160,6 @@ export default defineConfig({ 'vaul', '@qwen-code/sdk', /^@qwen-code\/sdk\//, - '@qwen-code/webui', - /^@qwen-code\/webui\//, '@datafe-open/markdown-chart', '@datafe-open/markdown-chart-echarts', '@datafe-open/markdown-chart-react', diff --git a/packages/web-shell/vitest.config.ts b/packages/web-shell/vitest.config.ts index 607bf6ffdd2..3e801870451 100644 --- a/packages/web-shell/vitest.config.ts +++ b/packages/web-shell/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ root: 'client', resolve: { alias: { + '@qwen-code/web-shell/daemon-react-sdk': resolve( + __dirname, + './client/daemon-react-sdk.ts', + ), '@': resolve(__dirname, './client'), }, }, diff --git a/packages/webui/README.md b/packages/webui/README.md index 359a1a078ed..179aa6b828d 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -170,66 +170,6 @@ function App() { } ``` -## Daemon React SDK (`@qwen-code/webui/daemon-react-sdk`) - -All daemon-related React bindings (Providers, hooks, types) are published under the `daemon-react-sdk` sub-path. The main entry (`@qwen-code/webui`) is purely UI components with zero daemon dependency. - -```tsx -import { - DaemonSessionProvider, - DaemonWorkspaceProvider, - useTranscriptBlocks, - useConnection, - useActions, - useStreamingState, -} from '@qwen-code/webui/daemon-react-sdk'; -``` - -### Architecture - -Two providers, split by lifecycle axis: - -- **`DaemonSessionProvider`** — per-conversation: SSE connection, transcript store, prompt/cancel/model/approval-mode/permission actions. -- **`DaemonWorkspaceProvider`** — per-workspace (outlives sessions): MCP, skills, tools, memory, agents, files. - -``` - ← owns DaemonClient + capabilities - useMcp / useAgents / useMemory / useTools / ... - ├── ← owns session + SSE + transcript store - │ useTranscriptBlocks / useActions / useConnection / useStreamingState / ... - │ ├── - │ └── -``` - -### Basic usage - -```tsx -import { - DaemonSessionProvider, - DaemonWorkspaceProvider, - useTranscriptBlocks, - useActions, - useConnection, -} from '@qwen-code/webui/daemon-react-sdk'; - -function App() { - return ( - - - - - - ); -} - -function ChatView() { - const blocks = useTranscriptBlocks(); - const { sendPrompt, cancel } = useActions(); - const { status, sessionId, currentModel } = useConnection(); - // render blocks, handle input... -} -``` - ### Dual-mode usage (chat + terminal share one session) Wrap both views with a **single** ``. Both panels share one SSE connection and one transcript store. diff --git a/packages/webui/package.json b/packages/webui/package.json index 14b6e751e2d..d4ef3ad6fc4 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -12,11 +12,6 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./daemon-react-sdk": { - "types": "./dist/daemon-react-sdk.d.ts", - "import": "./dist/daemon-react-sdk.js", - "require": "./dist/daemon-react-sdk.cjs" - }, "./icons": { "types": "./dist/components/icons/index.d.ts", "import": "./dist/components/icons/index.js", diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts index fc16c913bde..33cc5db1aec 100644 --- a/packages/webui/vite.config.ts +++ b/packages/webui/vite.config.ts @@ -14,7 +14,6 @@ import { resolve } from 'path'; * * Build outputs: * - Main entry: dist/index.js, dist/index.cjs, dist/index.d.ts - * - Advanced entry: dist/advanced.js, dist/advanced.cjs, dist/advanced.d.ts * - CSS: dist/styles.css */ export default defineConfig(({ command }) => ({ @@ -47,7 +46,6 @@ export default defineConfig(({ command }) => ({ lib: { entry: { index: resolve(__dirname, 'src/index.ts'), - 'daemon-react-sdk': resolve(__dirname, 'src/daemon-react-sdk.ts'), }, formats: ['es', 'cjs'], }, diff --git a/scripts/tests/vscode-companion-no-webui-config.test.js b/scripts/tests/vscode-companion-no-webui-config.test.js new file mode 100644 index 00000000000..602f1575f7a --- /dev/null +++ b/scripts/tests/vscode-companion-no-webui-config.test.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +const eslint = new ESLint({ cwd: root }); + +async function restrictedReports(statement) { + const filePath = join( + root, + 'packages/vscode-ide-companion/src/boundary-probe.ts', + ); + const [result] = await eslint.lintText(`${statement}\n`, { filePath }); + return result.messages.filter((m) => m.ruleId === 'no-restricted-imports'); +} + +it('blocks @qwen-code/webui imports from the VS Code companion', async () => { + expect( + await restrictedReports(`import { App } from '@qwen-code/webui';`), + ).toHaveLength(1); + expect( + await restrictedReports( + `import type { AppProps } from '@qwen-code/webui';`, + ), + ).toHaveLength(1); + expect( + await restrictedReports(`export { App } from '@qwen-code/webui';`), + ).toHaveLength(1); +}); + +it('blocks deep @qwen-code/webui specifiers from the VS Code companion', async () => { + expect( + await restrictedReports( + `import { styles } from '@qwen-code/webui/styles';`, + ), + ).toHaveLength(1); +}); + +it('still allows @qwen-code/web-shell imports in the VS Code companion', async () => { + expect( + await restrictedReports( + `import { WebShellApp } from '@qwen-code/web-shell';`, + ), + ).toHaveLength(0); +}); diff --git a/scripts/version.js b/scripts/version.js index d9f2f4f8737..23f8e569b06 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -49,36 +49,10 @@ const workspacesToExclude = [ '@qwen-code/mobile-mcp', '@qwen-code/node-repl-mcp', ]; -let lsOutput; -try { - lsOutput = JSON.parse( - execSync('npm ls --workspaces --json --depth=0').toString(), - ); -} catch (e) { - // `npm ls` can exit with a non-zero status code if there are issues - // with dependencies, but it will still produce the JSON output we need. - // We'll try to parse the stdout from the error object. - if (e.stdout) { - console.warn( - 'Warning: `npm ls` exited with a non-zero status code. Attempting to proceed with the output.', - ); - try { - lsOutput = JSON.parse(e.stdout.toString()); - } catch (parseError) { - console.error( - 'Error: Failed to parse JSON from `npm ls` output even after `npm ls` failed.', - ); - console.error('npm ls stderr:', e.stderr.toString()); - console.error('Parse error:', parseError); - process.exit(1); - } - } else { - console.error('Error: `npm ls` failed with no output.'); - console.error(e.stderr?.toString() || e); - process.exit(1); - } -} -const allWorkspaces = Object.keys(lsOutput.dependencies || {}); +const workspaceNames = JSON.parse( + execSync('npm pkg get name --workspaces --json').toString(), +); +const allWorkspaces = Object.keys(workspaceNames); const workspacesToVersion = allWorkspaces.filter( (wsName) => !workspacesToExclude.includes(wsName), );