From ffb612aceb07d390b9c8c46c554d2799be252fa8 Mon Sep 17 00:00:00 2001 From: Zeyu Liu Date: Tue, 18 Aug 2026 14:26:23 +0800 Subject: [PATCH 01/22] feat(samples): add TypeScript web-ui-showcase sample with specs and plans --- typescript/web-ui-showcase/.env.example | 13 + typescript/web-ui-showcase/.gitignore | 5 + typescript/web-ui-showcase/README.md | 157 +++ typescript/web-ui-showcase/index.html | 12 + typescript/web-ui-showcase/package.json | 47 + .../web-ui-showcase/playwright.config.ts | 18 + .../scripts/check-sdk-import-boundary.mjs | 39 + .../scripts/start-production.mjs | 2 + typescript/web-ui-showcase/src/client/app.tsx | 29 + .../src/client/components/safe-json.tsx | 9 + .../conversation/composer-control-menu.tsx | 180 ++++ .../features/conversation/composer-drafts.ts | 23 + .../conversation/composer-suggestion-list.tsx | 66 ++ .../conversation/composer-suggestions.ts | 168 ++++ .../features/conversation/context-summary.ts | 64 ++ .../conversation/conversation-panel.tsx | 59 ++ .../conversation/conversation-root.tsx | 259 +++++ .../features/conversation/message-item.tsx | 43 + .../features/conversation/message-list.tsx | 51 + .../features/conversation/prompt-composer.tsx | 552 +++++++++++ .../features/conversation/tool-card.tsx | 48 + .../features/conversation/tool-details.tsx | 42 + .../features/errors/app-error-boundary.tsx | 16 + .../errors/command-failure-notice.tsx | 28 + .../client/features/errors/error-banner.tsx | 12 + .../client/features/errors/event-boundary.tsx | 27 + .../interactions/interaction-card.tsx | 129 +++ .../client/features/interactions/mcp-form.tsx | 156 +++ .../src/client/features/layout/app-shell.tsx | 257 +++++ .../client/features/layout/app-sidebar.tsx | 60 ++ .../src/client/features/layout/columns.ts | 62 ++ .../client/features/layout/details-panel.tsx | 83 ++ .../features/layout/details-selection.ts | 6 + .../src/client/features/layout/drawer.tsx | 26 + .../src/client/features/layout/modal-focus.ts | 104 ++ .../features/layout/subagent-details.tsx | 122 +++ .../src/client/features/mcp/mcp-panel.tsx | 14 + .../features/runtime/credits-account.tsx | 47 + .../client/features/runtime/model-picker.tsx | 50 + .../features/runtime/permission-picker.tsx | 47 + .../features/runtime/runtime-dialog.tsx | 412 ++++++++ .../features/sdk-console/sdk-console.tsx | 69 ++ .../sessions/session-action-dialog.tsx | 142 +++ .../features/sessions/session-actions.tsx | 9 + .../client/features/sessions/session-menu.tsx | 175 ++++ .../client/features/sessions/session-row.tsx | 118 +++ .../client/features/sessions/session-tree.tsx | 64 ++ .../sessions/use-session-selection.ts | 146 +++ .../client/features/tasks/task-details.tsx | 95 ++ .../features/workspaces/workspace-dialog.tsx | 57 ++ .../features/workspaces/workspace-panel.tsx | 66 ++ .../web-ui-showcase/src/client/i18n/zh-cn.ts | 187 ++++ .../web-ui-showcase/src/client/main.tsx | 16 + .../src/client/store/app-reducer.ts | 339 +++++++ .../src/client/store/app-state.ts | 53 + .../src/client/store/app-store.ts | 161 +++ .../src/client/store/command-ownership.ts | 94 ++ .../src/client/store/store-context.tsx | 32 + .../web-ui-showcase/src/client/styles.css | 6 + .../src/client/styles/composer.css | 36 + .../src/client/styles/conversation.css | 60 ++ .../src/client/styles/layout.css | 58 ++ .../src/client/styles/overlays.css | 58 ++ .../src/client/styles/sidebar.css | 46 + .../src/client/styles/tokens.css | 73 ++ .../src/client/transport/api-client.ts | 272 +++++ .../src/client/transport/realtime-client.ts | 165 +++ .../src/server/api/checkpoint-routes.ts | 49 + .../src/server/api/command-runner.ts | 43 + .../src/server/api/error-handler.ts | 34 + .../src/server/api/interaction-routes.ts | 49 + .../src/server/api/mcp-routes.ts | 74 ++ .../src/server/api/runtime-routes.ts | 158 +++ .../src/server/api/session-routes.ts | 180 ++++ .../src/server/api/workspace-file-routes.ts | 27 + .../src/server/api/workspace-routes.ts | 52 + typescript/web-ui-showcase/src/server/app.ts | 310 ++++++ .../web-ui-showcase/src/server/config.ts | 164 +++ .../src/server/errors/app-error.ts | 61 ++ typescript/web-ui-showcase/src/server/main.ts | 22 + .../persistence/workspace-repository.ts | 152 +++ .../src/server/platform/directory-picker.ts | 3 + .../platform/native-directory-picker.ts | 128 +++ .../src/server/platform/path-policy.ts | 85 ++ .../src/server/realtime/event-journal.ts | 99 ++ .../src/server/realtime/realtime-hub.ts | 131 +++ .../web-ui-showcase/src/server/sdk/README.md | 82 ++ .../src/server/sdk/ask-user.ts | 51 + .../src/server/sdk/browser-projection.ts | 16 + .../src/server/sdk/checkpoint-service.ts | 281 ++++++ .../server/sdk/composer-command-catalog.ts | 89 ++ .../src/server/sdk/demo-mcp-server.ts | 50 + .../src/server/sdk/error-text-redact.ts | 138 +++ .../src/server/sdk/history-projector.ts | 224 +++++ .../web-ui-showcase/src/server/sdk/hooks.ts | 52 + .../src/server/sdk/input-queue.ts | 203 ++++ .../src/server/sdk/interaction-broker.ts | 490 +++++++++ .../src/server/sdk/mcp-config.ts | 168 ++++ .../src/server/sdk/mcp-service.ts | 254 +++++ .../src/server/sdk/message-projector.ts | 487 +++++++++ .../src/server/sdk/product-user-message.ts | 20 + .../src/server/sdk/query-factory.ts | 92 ++ .../src/server/sdk/query-port.ts | 64 ++ .../web-ui-showcase/src/server/sdk/redact.ts | 357 +++++++ .../server/sdk/runtime-capability-service.ts | 312 ++++++ .../src/server/sdk/sdk-public-contract.ts | 35 + .../src/server/sdk/session-catalog.ts | 91 ++ .../src/server/sdk/session-controller.ts | 632 ++++++++++++ .../src/server/sdk/session-registry.ts | 172 ++++ .../src/server/sdk/session-runtime-state.ts | 135 +++ .../server/services/session-catalog-port.ts | 40 + .../src/server/services/session-service.ts | 513 ++++++++++ .../server/services/session-start-service.ts | 47 + .../src/server/services/snapshot-service.ts | 268 +++++ .../services/subagent-transcript-service.ts | 41 + .../server/services/workspace-file-service.ts | 197 ++++ .../src/server/services/workspace-service.ts | 157 +++ .../web-ui-showcase/src/server/shutdown.ts | 16 + .../web-ui-showcase/src/shared/commands.ts | 176 ++++ .../web-ui-showcase/src/shared/errors.ts | 51 + .../web-ui-showcase/src/shared/events.ts | 171 ++++ .../web-ui-showcase/src/shared/frames.ts | 20 + typescript/web-ui-showcase/src/shared/ids.ts | 16 + .../src/shared/mcp-elicitation-schema.ts | 602 +++++++++++ .../web-ui-showcase/src/shared/model.ts | 371 +++++++ .../web-ui-showcase/src/shared/permissions.ts | 15 + .../web-ui-showcase/src/shared/snapshots.ts | 31 + .../web-ui-showcase/src/shared/subagents.ts | 16 + .../src/shared/workspace-files.ts | 27 + .../test/e2e/fixture-server.ts | 70 ++ .../web-ui-showcase/test/e2e/showcase.spec.ts | 488 +++++++++ .../test/fixtures/fake-query.ts | 718 ++++++++++++++ .../test/fixtures/fake-sdk-runtime.ts | 199 ++++ .../test/integration/checkpoints.test.ts | 164 +++ .../test/integration/error-recovery.test.ts | 135 +++ .../integration/graceful-shutdown.test.ts | 113 +++ .../test/integration/health.test.ts | 61 ++ .../test/integration/interactions.test.ts | 82 ++ .../test/integration/mcp.test.ts | 154 +++ .../test/integration/realtime.test.ts | 386 ++++++++ .../integration/restart-hydration.test.ts | 246 +++++ .../integration/runtime-capabilities.test.ts | 587 +++++++++++ .../test/integration/sessions.test.ts | 927 +++++++++++++++++ .../test/integration/workspace-files.test.ts | 71 ++ .../test/integration/workspaces.test.ts | 251 +++++ typescript/web-ui-showcase/test/setup.ts | 1 + .../test/smoke/production-server-smoke.ts | 83 ++ .../test/smoke/real-sdk-smoke.ts | 139 +++ .../test/unit/client/api-client.test.ts | 188 ++++ .../test/unit/client/app-reducer.test.ts | 393 ++++++++ .../unit/client/command-ownership.test.ts | 301 ++++++ .../unit/client/composer-suggestions.test.ts | 209 ++++ .../test/unit/client/context-summary.test.ts | 48 + .../test/unit/client/conversation-ui.test.tsx | 774 +++++++++++++++ .../test/unit/client/drawer.test.tsx | 51 + .../test/unit/client/error-ui.test.tsx | 575 +++++++++++ .../test/unit/client/layout-state.test.ts | 94 ++ .../test/unit/client/mcp-elicitation.test.tsx | 190 ++++ .../test/unit/client/modal-focus.test.tsx | 169 ++++ .../test/unit/client/prompt-composer.test.tsx | 770 ++++++++++++++ .../test/unit/client/realtime-client.test.ts | 298 ++++++ .../test/unit/client/runtime-ui.test.tsx | 328 ++++++ .../test/unit/client/sdk-console.test.tsx | 142 +++ .../test/unit/client/session-menu.test.tsx | 124 +++ .../unit/client/workspace-session-ui.test.tsx | 784 +++++++++++++++ .../test/unit/server/errors/app-error.test.ts | 35 + .../persistence/workspace-repository.test.ts | 66 ++ .../platform/native-directory-picker.test.ts | 94 ++ .../unit/server/platform/path-policy.test.ts | 56 ++ .../server/realtime/event-journal.test.ts | 69 ++ .../test/unit/server/sdk/ask-user.test.ts | 51 + .../sdk/composer-command-catalog.test.ts | 158 +++ .../unit/server/sdk/demo-mcp-server.test.ts | 36 + .../unit/server/sdk/history-projector.test.ts | 335 +++++++ .../test/unit/server/sdk/hooks.test.ts | 100 ++ .../test/unit/server/sdk/input-queue.test.ts | 88 ++ .../server/sdk/interaction-broker.test.ts | 703 +++++++++++++ .../test/unit/server/sdk/mcp-config.test.ts | 57 ++ .../unit/server/sdk/message-projector.test.ts | 882 +++++++++++++++++ .../unit/server/sdk/query-factory.test.ts | 146 +++ .../test/unit/server/sdk/redact.test.ts | 116 +++ .../unit/server/sdk/session-catalog.test.ts | 119 +++ .../server/sdk/session-controller.test.ts | 937 ++++++++++++++++++ .../unit/server/sdk/session-registry.test.ts | 229 +++++ .../server/sdk/session-runtime-state.test.ts | 191 ++++ .../services/workspace-file-service.test.ts | 304 ++++++ .../test/unit/shared/commands.test.ts | 39 + .../shared/mcp-elicitation-schema.test.ts | 476 +++++++++ .../test/unit/shared/model.test.ts | 86 ++ .../test/unit/shared/protocol.test.ts | 145 +++ .../web-ui-showcase/tsconfig.client.json | 12 + typescript/web-ui-showcase/tsconfig.json | 11 + .../web-ui-showcase/tsconfig.server.json | 11 + typescript/web-ui-showcase/tsconfig.test.json | 20 + typescript/web-ui-showcase/vite.config.ts | 15 + typescript/web-ui-showcase/vitest.config.ts | 10 + 196 files changed, 31142 insertions(+) create mode 100644 typescript/web-ui-showcase/.env.example create mode 100644 typescript/web-ui-showcase/.gitignore create mode 100644 typescript/web-ui-showcase/README.md create mode 100644 typescript/web-ui-showcase/index.html create mode 100644 typescript/web-ui-showcase/package.json create mode 100644 typescript/web-ui-showcase/playwright.config.ts create mode 100644 typescript/web-ui-showcase/scripts/check-sdk-import-boundary.mjs create mode 100644 typescript/web-ui-showcase/scripts/start-production.mjs create mode 100644 typescript/web-ui-showcase/src/client/app.tsx create mode 100644 typescript/web-ui-showcase/src/client/components/safe-json.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/composer-control-menu.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/composer-drafts.ts create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/composer-suggestion-list.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/composer-suggestions.ts create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/context-summary.ts create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/conversation-panel.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/conversation-root.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/message-item.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/message-list.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/prompt-composer.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/tool-card.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/conversation/tool-details.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/errors/app-error-boundary.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/errors/command-failure-notice.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/errors/error-banner.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/errors/event-boundary.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/interactions/interaction-card.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/interactions/mcp-form.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/layout/app-shell.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/layout/app-sidebar.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/layout/columns.ts create mode 100644 typescript/web-ui-showcase/src/client/features/layout/details-panel.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/layout/details-selection.ts create mode 100644 typescript/web-ui-showcase/src/client/features/layout/drawer.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/layout/modal-focus.ts create mode 100644 typescript/web-ui-showcase/src/client/features/layout/subagent-details.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/mcp/mcp-panel.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/runtime/credits-account.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/runtime/model-picker.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/runtime/permission-picker.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/runtime/runtime-dialog.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sdk-console/sdk-console.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/session-action-dialog.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/session-actions.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/session-menu.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/session-row.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/session-tree.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/sessions/use-session-selection.ts create mode 100644 typescript/web-ui-showcase/src/client/features/tasks/task-details.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/workspaces/workspace-dialog.tsx create mode 100644 typescript/web-ui-showcase/src/client/features/workspaces/workspace-panel.tsx create mode 100644 typescript/web-ui-showcase/src/client/i18n/zh-cn.ts create mode 100644 typescript/web-ui-showcase/src/client/main.tsx create mode 100644 typescript/web-ui-showcase/src/client/store/app-reducer.ts create mode 100644 typescript/web-ui-showcase/src/client/store/app-state.ts create mode 100644 typescript/web-ui-showcase/src/client/store/app-store.ts create mode 100644 typescript/web-ui-showcase/src/client/store/command-ownership.ts create mode 100644 typescript/web-ui-showcase/src/client/store/store-context.tsx create mode 100644 typescript/web-ui-showcase/src/client/styles.css create mode 100644 typescript/web-ui-showcase/src/client/styles/composer.css create mode 100644 typescript/web-ui-showcase/src/client/styles/conversation.css create mode 100644 typescript/web-ui-showcase/src/client/styles/layout.css create mode 100644 typescript/web-ui-showcase/src/client/styles/overlays.css create mode 100644 typescript/web-ui-showcase/src/client/styles/sidebar.css create mode 100644 typescript/web-ui-showcase/src/client/styles/tokens.css create mode 100644 typescript/web-ui-showcase/src/client/transport/api-client.ts create mode 100644 typescript/web-ui-showcase/src/client/transport/realtime-client.ts create mode 100644 typescript/web-ui-showcase/src/server/api/checkpoint-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/command-runner.ts create mode 100644 typescript/web-ui-showcase/src/server/api/error-handler.ts create mode 100644 typescript/web-ui-showcase/src/server/api/interaction-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/mcp-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/runtime-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/session-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/workspace-file-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/api/workspace-routes.ts create mode 100644 typescript/web-ui-showcase/src/server/app.ts create mode 100644 typescript/web-ui-showcase/src/server/config.ts create mode 100644 typescript/web-ui-showcase/src/server/errors/app-error.ts create mode 100644 typescript/web-ui-showcase/src/server/main.ts create mode 100644 typescript/web-ui-showcase/src/server/persistence/workspace-repository.ts create mode 100644 typescript/web-ui-showcase/src/server/platform/directory-picker.ts create mode 100644 typescript/web-ui-showcase/src/server/platform/native-directory-picker.ts create mode 100644 typescript/web-ui-showcase/src/server/platform/path-policy.ts create mode 100644 typescript/web-ui-showcase/src/server/realtime/event-journal.ts create mode 100644 typescript/web-ui-showcase/src/server/realtime/realtime-hub.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/README.md create mode 100644 typescript/web-ui-showcase/src/server/sdk/ask-user.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/browser-projection.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/checkpoint-service.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/composer-command-catalog.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/demo-mcp-server.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/error-text-redact.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/history-projector.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/hooks.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/input-queue.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/interaction-broker.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/mcp-config.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/mcp-service.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/message-projector.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/product-user-message.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/query-factory.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/query-port.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/redact.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/runtime-capability-service.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/sdk-public-contract.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/session-catalog.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/session-controller.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/session-registry.ts create mode 100644 typescript/web-ui-showcase/src/server/sdk/session-runtime-state.ts create mode 100644 typescript/web-ui-showcase/src/server/services/session-catalog-port.ts create mode 100644 typescript/web-ui-showcase/src/server/services/session-service.ts create mode 100644 typescript/web-ui-showcase/src/server/services/session-start-service.ts create mode 100644 typescript/web-ui-showcase/src/server/services/snapshot-service.ts create mode 100644 typescript/web-ui-showcase/src/server/services/subagent-transcript-service.ts create mode 100644 typescript/web-ui-showcase/src/server/services/workspace-file-service.ts create mode 100644 typescript/web-ui-showcase/src/server/services/workspace-service.ts create mode 100644 typescript/web-ui-showcase/src/server/shutdown.ts create mode 100644 typescript/web-ui-showcase/src/shared/commands.ts create mode 100644 typescript/web-ui-showcase/src/shared/errors.ts create mode 100644 typescript/web-ui-showcase/src/shared/events.ts create mode 100644 typescript/web-ui-showcase/src/shared/frames.ts create mode 100644 typescript/web-ui-showcase/src/shared/ids.ts create mode 100644 typescript/web-ui-showcase/src/shared/mcp-elicitation-schema.ts create mode 100644 typescript/web-ui-showcase/src/shared/model.ts create mode 100644 typescript/web-ui-showcase/src/shared/permissions.ts create mode 100644 typescript/web-ui-showcase/src/shared/snapshots.ts create mode 100644 typescript/web-ui-showcase/src/shared/subagents.ts create mode 100644 typescript/web-ui-showcase/src/shared/workspace-files.ts create mode 100644 typescript/web-ui-showcase/test/e2e/fixture-server.ts create mode 100644 typescript/web-ui-showcase/test/e2e/showcase.spec.ts create mode 100644 typescript/web-ui-showcase/test/fixtures/fake-query.ts create mode 100644 typescript/web-ui-showcase/test/fixtures/fake-sdk-runtime.ts create mode 100644 typescript/web-ui-showcase/test/integration/checkpoints.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/error-recovery.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/graceful-shutdown.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/health.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/interactions.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/mcp.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/realtime.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/restart-hydration.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/runtime-capabilities.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/sessions.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/workspace-files.test.ts create mode 100644 typescript/web-ui-showcase/test/integration/workspaces.test.ts create mode 100644 typescript/web-ui-showcase/test/setup.ts create mode 100644 typescript/web-ui-showcase/test/smoke/production-server-smoke.ts create mode 100644 typescript/web-ui-showcase/test/smoke/real-sdk-smoke.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/api-client.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/app-reducer.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/command-ownership.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/composer-suggestions.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/context-summary.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/conversation-ui.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/drawer.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/error-ui.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/layout-state.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/mcp-elicitation.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/modal-focus.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/prompt-composer.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/realtime-client.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/client/runtime-ui.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/sdk-console.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/session-menu.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/client/workspace-session-ui.test.tsx create mode 100644 typescript/web-ui-showcase/test/unit/server/errors/app-error.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/persistence/workspace-repository.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/platform/native-directory-picker.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/platform/path-policy.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/realtime/event-journal.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/ask-user.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/composer-command-catalog.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/demo-mcp-server.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/history-projector.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/hooks.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/input-queue.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/interaction-broker.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/mcp-config.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/message-projector.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/query-factory.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/redact.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/session-catalog.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/session-controller.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/session-registry.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/sdk/session-runtime-state.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/server/services/workspace-file-service.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/shared/commands.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/shared/mcp-elicitation-schema.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/shared/model.test.ts create mode 100644 typescript/web-ui-showcase/test/unit/shared/protocol.test.ts create mode 100644 typescript/web-ui-showcase/tsconfig.client.json create mode 100644 typescript/web-ui-showcase/tsconfig.json create mode 100644 typescript/web-ui-showcase/tsconfig.server.json create mode 100644 typescript/web-ui-showcase/tsconfig.test.json create mode 100644 typescript/web-ui-showcase/vite.config.ts create mode 100644 typescript/web-ui-showcase/vitest.config.ts diff --git a/typescript/web-ui-showcase/.env.example b/typescript/web-ui-showcase/.env.example new file mode 100644 index 0000000..0a9a885 --- /dev/null +++ b/typescript/web-ui-showcase/.env.example @@ -0,0 +1,13 @@ +QODER_WEBUI_HOST=127.0.0.1 +QODER_WEBUI_PORT=8787 +QODER_WEBUI_AUTH=cli +QODER_WEBUI_MODEL=auto +QODER_WEBUI_PERMISSION_MODE=default +QODER_WEBUI_EVENT_CAPACITY=1000 +QODER_WEBUI_CHECKPOINTS=true +QODER_WEBUI_RAW_EVENTS=true +# QODER_PERSONAL_ACCESS_TOKEN= +# QODER_WEBUI_DEV_ORIGIN=http://127.0.0.1:5173 +# QODER_WEBUI_DATA_DIR=/absolute/path/to/local/app-data +# QODER_WEBUI_MCP_CONFIG_FILE=/absolute/path/to/mcp-servers.json +# QODER_WEBUI_EXTENSIONS_CONFIG_FILE=/absolute/path/to/extensions.json diff --git a/typescript/web-ui-showcase/.gitignore b/typescript/web-ui-showcase/.gitignore new file mode 100644 index 0000000..992da40 --- /dev/null +++ b/typescript/web-ui-showcase/.gitignore @@ -0,0 +1,5 @@ +.env +dist/ +coverage/ +playwright-report/ +test-results/ diff --git a/typescript/web-ui-showcase/README.md b/typescript/web-ui-showcase/README.md new file mode 100644 index 0000000..f12f855 --- /dev/null +++ b/typescript/web-ui-showcase/README.md @@ -0,0 +1,157 @@ +# Qoder Agent SDK Web UI Showcase + +This sample is a complete local Web UI application built with the Qoder TypeScript SDK. It is intended as an open-source application template: the product UI stays focused on project work, while the source demonstrates how Session, streaming messages, Approval, MCP, Hooks, Task, Credits, errors, Checkpoint, shutdown, and recovery fit together. + +The browser uses a Chinese, light-theme product shell. Established SDK concepts such as Session, Workspace, Model, Permission, MCP, Hooks, Task, Checkpoint, Credits, Skill, Command, and Tool retain their English names. Model and Permission Mode are compact Composer selectors, MCP remains in Settings and `/mcp`, and Tool input/result expands beneath its transcript row. + +## Run the sample + +Requirements: + +- Node.js 22 or later. +- A Chromium browser for Playwright acceptance tests. +- Either an existing `qodercli` login or a Qoder personal access token. + +Install all TypeScript sample dependencies from the parent directory: + +```bash +cd typescript +npm install +npx playwright install chromium +cd web-ui-showcase +cp .env.example .env +``` + +Development mode starts Fastify on `127.0.0.1:8787` and Vite on `127.0.0.1:5173`: + +```bash +npm run dev +``` + +Open [http://127.0.0.1:5173](http://127.0.0.1:5173). + +Build and run the production server: + +```bash +npm run build +QODER_WEBUI_HOST=127.0.0.1 QODER_WEBUI_PORT=8787 npm start +``` + +Open [http://127.0.0.1:8787](http://127.0.0.1:8787). + +Authentication defaults to `QODER_WEBUI_AUTH=cli`, which calls `qodercliAuth()`. To use an access token, set `QODER_WEBUI_AUTH=access-token` and provide `QODER_PERSONAL_ACCESS_TOKEN` in the environment or an uncommitted `.env`. Credentials are never accepted by the browser UI or included in browser view models. + +## Product behavior + +The home screen contains one Composer. A user may type before choosing a Workspace. If the first Send has no Workspace, the server opens the native directory picker, preserves the draft, registers the canonical directory, creates the Session, and submits that same first message as one operation. + +Each Session is permanently associated with its Workspace root. Selecting a Session immediately selects its projected transcript and asks the server to make its SDK Query available. Concurrent availability requests are deduplicated. A specific failure appears in the conversation; selecting the Session again starts a fresh attempt. SDK controller phases are not product controls. + +The conversation is a semantic projection rather than a card for every SDK event: + +- text deltas and the final Assistant message update one Assistant item; +- Tool input, lifecycle, result, and timing update one Tool row by tool-use identifier; +- clicking an ordinary Tool expands its input and result beneath the same row; +- an `Agent` Tool opens contextual Subagent Details; its instruction, Assistant messages, and internal Tools stay out of the main transcript and retain their own event order; +- Assistant text is split at Tool boundaries so live and restored transcripts retain SDK event order; +- SDK control receipts and standalone Task summaries stay out of the product transcript; +- Approval, `AskUserQuestion`, and MCP elicitation remain actionable inline; +- turn errors stay with the affected turn and command failures stay with their control; +- only a realtime protocol or connection failure uses the global banner. + +The Composer owns Session-scoped drafts. Enter sends ordinary input, Shift+Enter inserts a newline, and Enter or Tab completes an open suggestion. Arrow keys keep the active option visible. Newline-delimited SDK prompt Suggestions are normalized into separate actions; selecting one fills the draft without sending it. `/model` and `/permissions` focus their inline selectors; `/mcp` opens MCP settings. SDK Commands and Skills use the SDK input path. `@ Files` searches the Session Workspace and explicitly allowed directories without reading file content into suggestions. Only commands with an implemented execution strategy are advertised. + +The document is fixed to the viewport. The Session list, transcript, contextual details, dialogs, and suggestion lists are the intentional scroll regions. The Session header and Composer remain visible while transcript history scrolls. Desktop side panels are resizable within guarded ranges; narrow layouts use overlay panels and preserve keyboard focus. + +## Architecture + +```mermaid +flowchart LR + Browser["React browser\nproduct view models"] -->|validated REST commands| API["Fastify API"] + API --> Services["Workspace and Session services"] + Services --> Adapters["SDK adapters"] + Adapters --> SDK["Qoder TypeScript SDK"] + Adapters --> Journal["redacted ordered journal"] + Journal -->|snapshot + events| Browser + Services --> Files["registered local roots"] +``` + +The browser is SDK-agnostic. [`src/shared/`](src/shared) defines validated commands, snapshots, events, and browser-safe view models. [`src/client/transport/`](src/client/transport) sends commands and recovers ordered realtime delivery. [`src/client/store/`](src/client/store) reduces snapshots and idempotent events into normalized product state. + +Only [`src/server/sdk/`](src/server/sdk) may import `@qoder-ai/qoder-agent-sdk`. [`src/server/services/`](src/server/services) owns application policy without importing SDK types, and [`src/server/api/`](src/server/api) validates one request before delegating one operation. Run `npm run check:boundary` to enforce that dependency direction. + +| Capability | Primary implementation | Product placement | +| --- | --- | --- | +| Session creation and automatic availability | [`session-start-service.ts`](src/server/services/session-start-service.ts), [`session-registry.ts`](src/server/sdk/session-registry.ts) | Hero and Session sidebar | +| Message queue and semantic stream | [`input-queue.ts`](src/server/sdk/input-queue.ts), [`message-projector.ts`](src/server/sdk/message-projector.ts) | Composer and conversation | +| Subagent history | public SDK `listSubagents` and `getSubagentMessages` through [`session-catalog.ts`](src/server/sdk/session-catalog.ts), correlated by [`subagent-transcript-service.ts`](src/server/services/subagent-transcript-service.ts) | `Agent` Tool and contextual Details | +| Approval and questions | [`interaction-broker.ts`](src/server/sdk/interaction-broker.ts), [`interaction-card.tsx`](src/client/features/interactions/interaction-card.tsx) | Inline conversation | +| MCP | [`mcp-service.ts`](src/server/sdk/mcp-service.ts), [`mcp-panel.tsx`](src/client/features/mcp/mcp-panel.tsx) | Inline elicitation and MCP settings | +| Hooks | [`hooks.ts`](src/server/sdk/hooks.ts) | SDK Console | +| Task | [`runtime-capability-service.ts`](src/server/sdk/runtime-capability-service.ts), [`runtime-routes.ts`](src/server/api/runtime-routes.ts) | SDK lifecycle state and command API without a standalone transcript card | +| Credits and Account | [`runtime-capability-service.ts`](src/server/sdk/runtime-capability-service.ts) | Account settings | +| Checkpoint | [`checkpoint-service.ts`](src/server/sdk/checkpoint-service.ts), [`api-client.ts`](src/client/transport/api-client.ts) | SDK adapter and API example; intentionally omitted from the product transcript | +| Errors | [`error-handler.ts`](src/server/api/error-handler.ts), [`message-projector.ts`](src/server/sdk/message-projector.ts) | Owning turn/control or global transport banner | +| Recovery and exit | [`realtime-hub.ts`](src/server/realtime/realtime-hub.ts), [`shutdown.ts`](src/server/shutdown.ts) | Automatic snapshot recovery and server lifecycle | + +Hooks and Raw Events are diagnostic rather than ordinary product interactions. They live only in the default-closed SDK Console. Entries are redacted before projection and bounded by depth, node count, serialized byte size, and journal capacity. Set `QODER_WEBUI_RAW_EVENTS=false` to omit Raw Events while retaining semantic conversation, Hook, Task, Credits, and error projections. + +Child-agent SDK messages carry `parent_tool_use_id`. The semantic projector keeps those records out of the main conversation while Raw Events may still observe their redacted diagnostics. Selecting the parent `Agent` Tool asks the server to associate that opaque Tool id with public SDK Subagent history, then returns only the strict projected transcript. The browser never reads SDK transcript files or fans out across agent ids. + +## Workspace and local-path safety + +[`workspace-service.ts`](src/server/services/workspace-service.ts) registers canonical directories selected through the native picker or an explicit local path. Session creation accepts a Workspace identifier, not an arbitrary browser-provided working directory. Existing Sessions cannot silently change roots. + +[`workspace-file-service.ts`](src/server/services/workspace-file-service.ts) searches only the canonical Workspace and server-registered allowed directories. It rechecks real paths, skips symlinks and generated directories, limits traversal depth and entry count, and returns paths rather than content. Final tool access and Approval remain the responsibility of the SDK runtime and selected Permission mode. + +The server binds only to `127.0.0.1`, `::1`, or `localhost`, applies the same exact Origin allowlist to REST and WebSocket requests, applies a CSP, validates JSON with Zod, and redacts credential-shaped fields. Requests without an Origin remain available to local CLI and test clients. This is a single-user local template, not a hardened hosted service. Do not expose its port through a reverse proxy or load untrusted MCP configuration without adding application-specific authentication and authorization. + +## MCP and extension configuration + +Every Session includes the read-only in-process `showcase_project` MCP server. Additional server-only MCP configuration may be loaded from `QODER_WEBUI_MCP_CONFIG_FILE`: + +```json +{ + "docs": { + "type": "http", + "url": "http://127.0.0.1:9000/mcp", + "tools": [{ "name": "search", "permission_policy": "always_ask" }] + } +} +``` + +Remote headers, subprocess environments, OAuth state, and callback processing stay on the server. The browser receives only the bounded status required by product controls. + +## Deterministic and real-SDK verification + +The default gate requires no Qoder account: + +```bash +npm run check +``` + +It runs all TypeScript programs, the SDK import boundary, unit tests, integration tests, the production build, a production HTTP/WebSocket smoke, and Playwright. [`test/e2e/fixture-server.ts`](test/e2e/fixture-server.ts) replaces the external Query, Workspace repository, native directory picker, and Session catalog with deterministic test adapters. The fixture still exercises the real browser, Fastify routes, application services, Session controller, semantic projection, event journal, and realtime recovery. Every Playwright journey explicitly clears prior fixture state and creates its own Workspace, Session, and turns. + +Individual commands are available when iterating: + +```bash +npm run typecheck +npm run check:boundary +npm run test:unit +npm run test:integration +npm run build +npm run test:smoke:production +npm run test:e2e +``` + +The opt-in smoke uses the installed Qoder TypeScript SDK and a real account: + +```bash +npm run test:smoke:real +``` + +It creates an isolated temporary Workspace and Session, completes one model turn, verifies SDK history, resumes the Session, deletes only that temporary Session, and removes the temporary directory. Without usable authentication it prints `SKIP`; a skip is not a passing real-account result. + +## Extending the template + +Add a browser-visible capability in four steps: define its shared command/event view model, implement the server adapter, reduce the event into normalized client state, and place the interaction in its normal product surface. Keep SDK objects and credentials server-side, correlate accepted commands by `commandId`, and add a deterministic journey that proves the assembled event order. diff --git a/typescript/web-ui-showcase/index.html b/typescript/web-ui-showcase/index.html new file mode 100644 index 0000000..7652ab6 --- /dev/null +++ b/typescript/web-ui-showcase/index.html @@ -0,0 +1,12 @@ + + + + + + Qoder Agent SDK Web UI + + +
+ + + diff --git a/typescript/web-ui-showcase/package.json b/typescript/web-ui-showcase/package.json new file mode 100644 index 0000000..bc20a48 --- /dev/null +++ b/typescript/web-ui-showcase/package.json @@ -0,0 +1,47 @@ +{ + "name": "@qoder-samples/typescript-web-ui-showcase", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently -k -n server,client npm:dev:server npm:dev:client", + "dev:server": "node --env-file-if-exists=.env --import tsx --watch src/server/main.ts", + "dev:client": "vite", + "build": "npm run typecheck && tsc -p tsconfig.server.json && vite build", + "start": "node --env-file-if-exists=.env scripts/start-production.mjs", + "typecheck": "tsc -p tsconfig.client.json --noEmit --pretty false && tsc -p tsconfig.server.json --noEmit --pretty false && tsc -p tsconfig.test.json --noEmit --pretty false", + "test:unit": "vitest run test/unit", + "test:integration": "vitest run test/integration", + "test:e2e": "playwright test", + "test:smoke:production": "node --import tsx test/smoke/production-server-smoke.ts", + "test:smoke:real": "node --env-file-if-exists=.env --import tsx test/smoke/real-sdk-smoke.ts", + "check:boundary": "node scripts/check-sdk-import-boundary.mjs", + "check": "npm run typecheck && npm run check:boundary && npm run test:unit && npm run test:integration && npm run build && npm run test:smoke:production && npm run test:e2e" + }, + "dependencies": { + "@fastify/static": "^8.0.0", + "@fastify/websocket": "^11.0.0", + "@qoder-ai/qoder-agent-sdk": "^1.0.21", + "fastify": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/ws": "^8.18.0", + "@vitejs/plugin-react": "^4.7.0", + "concurrently": "^9.2.0", + "jsdom": "^29.1.1", + "tsx": "^4.22.0", + "typescript": "^5.9.0", + "vite": "^6.4.3", + "vitest": "^4.1.8", + "ws": "^8.21.0" + } +} diff --git a/typescript/web-ui-showcase/playwright.config.ts b/typescript/web-ui-showcase/playwright.config.ts new file mode 100644 index 0000000..27d534a --- /dev/null +++ b/typescript/web-ui-showcase/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "test/e2e", + fullyParallel: false, + timeout: 30_000, + use: { + baseURL: "http://127.0.0.1:4178", + browserName: "chromium", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run build && ../node_modules/.bin/tsx test/e2e/fixture-server.ts", + url: "http://127.0.0.1:4178/api/health", + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/typescript/web-ui-showcase/scripts/check-sdk-import-boundary.mjs b/typescript/web-ui-showcase/scripts/check-sdk-import-boundary.mjs new file mode 100644 index 0000000..ec5473a --- /dev/null +++ b/typescript/web-ui-showcase/scripts/check-sdk-import-boundary.mjs @@ -0,0 +1,39 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const sourceRoot = fileURLToPath(new URL("../src/", import.meta.url)); + +async function walk(directory, prefix = "") { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + return entry.isDirectory() + ? walk(join(directory, entry.name), relative) + : [relative]; + }), + ); + return nested.flat(); +} + +const paths = (await walk(sourceRoot)).filter((path) => + /\.[cm]?tsx?$/.test(path), +); +const files = await Promise.all( + paths.map(async (path) => ({ + path, + source: await readFile(join(sourceRoot, path), "utf8"), + })), +); +const violations = files.filter( + ({ path, source }) => + source.includes("@qoder-ai/qoder-agent-sdk") && + !path.startsWith("server/sdk/"), +); +if (violations.length > 0) { + process.stderr.write( + `SDK import boundary violations:\n${violations.map(({ path }) => path).join("\n")}\n`, + ); + process.exitCode = 1; +} diff --git a/typescript/web-ui-showcase/scripts/start-production.mjs b/typescript/web-ui-showcase/scripts/start-production.mjs new file mode 100644 index 0000000..f92839b --- /dev/null +++ b/typescript/web-ui-showcase/scripts/start-production.mjs @@ -0,0 +1,2 @@ +process.env.NODE_ENV = "production"; +await import("../dist/node/server/main.js"); diff --git a/typescript/web-ui-showcase/src/client/app.tsx b/typescript/web-ui-showcase/src/client/app.tsx new file mode 100644 index 0000000..3b7c95c --- /dev/null +++ b/typescript/web-ui-showcase/src/client/app.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { AppShell } from "./features/layout/app-shell.js"; +import { AppErrorBoundary } from "./features/errors/app-error-boundary.js"; +import { AppStore } from "./store/app-store.js"; +import { StoreProvider } from "./store/store-context.js"; +import { ApiClient } from "./transport/api-client.js"; +import { RealtimeClient } from "./transport/realtime-client.js"; + +export function App(): JSX.Element { + const [resources] = useState(() => { + const store = new AppStore(); + return { + store, + api: new ApiClient(), + realtime: new RealtimeClient({ store }), + }; + }); + useEffect(() => { + resources.realtime.start(); + return () => resources.realtime.stop(); + }, [resources]); + return ( + + + + + + ); +} diff --git a/typescript/web-ui-showcase/src/client/components/safe-json.tsx b/typescript/web-ui-showcase/src/client/components/safe-json.tsx new file mode 100644 index 0000000..9512830 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/components/safe-json.tsx @@ -0,0 +1,9 @@ +export function SafeJson(props: { value: unknown }): JSX.Element { + let text: string; + try { + text = JSON.stringify(props.value, null, 2); + } catch { + text = "{\n \"error\": \"Unable to serialize this redacted event\"\n}"; + } + return
{text}
; +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/composer-control-menu.tsx b/typescript/web-ui-showcase/src/client/features/conversation/composer-control-menu.tsx new file mode 100644 index 0000000..66861d5 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/composer-control-menu.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState, type Ref } from "react"; +import type { SelectablePermissionMode } from "../../../shared/commands.js"; +import type { SessionRuntimeView } from "../../../shared/model.js"; +import { + runtimeRefreshCapability, + type RuntimeCapabilityId, +} from "../../../shared/errors.js"; +import type { ContextSummary } from "./context-summary.js"; +import { ModelPicker } from "../runtime/model-picker.js"; +import { PermissionPicker } from "../runtime/permission-picker.js"; + +export type RuntimeControlFailure = { + commandId?: string; + message: string; + dismiss?(): void; +}; + +export type RuntimeControl = "model" | "permission" | "mcp"; + +export function runtimeControlReason( + runtime: SessionRuntimeView | undefined, + control: RuntimeControl, +): string | undefined { + const capability: RuntimeCapabilityId = + control === "model" + ? "models" + : control === "permission" + ? "permission" + : "mcp"; + return runtime?.errors.find((error) => + runtimeRefreshCapability(error) === capability + )?.message; +} + +/** Session-scoped controls available from the shared Composer. */ +export function ComposerControlMenu(props: { + context: ContextSummary | null; + runtime?: SessionRuntimeView; + modelRef: Ref; + permissionRef: Ref; + setModel(model?: string): Promise<{ commandId: string }>; + setPermissionMode( + mode: SelectablePermissionMode, + ): Promise<{ commandId: string }>; + modelFailure?: RuntimeControlFailure; + permissionFailure?: RuntimeControlFailure; +}): JSX.Element { + const modelReason = runtimeControlReason(props.runtime, "model"); + const permissionReason = runtimeControlReason(props.runtime, "permission"); + const [pendingModel, setPendingModel] = useState<{ + commandId: string | null; + requested: string | null; + } | null>(null); + const [pendingPermission, setPendingPermission] = useState<{ + commandId: string | null; + requested: SelectablePermissionMode; + } | null>(null); + const [submissionError, setSubmissionError] = useState(null); + const runtime = props.runtime; + + useEffect(() => { + if ( + pendingModel !== null && + (runtime?.currentModel === pendingModel.requested || + (pendingModel.commandId !== null && + props.modelFailure?.commandId === pendingModel.commandId)) + ) { + setPendingModel(null); + } + }, [pendingModel, props.modelFailure?.commandId, runtime?.currentModel]); + + useEffect(() => { + if ( + pendingPermission !== null && + (runtime?.currentPermissionMode === pendingPermission.requested || + (pendingPermission.commandId !== null && + props.permissionFailure?.commandId === pendingPermission.commandId)) + ) { + setPendingPermission(null); + } + }, [ + pendingPermission, + props.permissionFailure?.commandId, + runtime?.currentPermissionMode, + ]); + + async function setModel(model?: string): Promise<{ commandId: string }> { + const requested = model ?? null; + props.modelFailure?.dismiss?.(); + setSubmissionError(null); + setPendingModel({ commandId: null, requested }); + try { + const accepted = await props.setModel(model); + setPendingModel((current) => + current?.requested === requested + ? { commandId: accepted.commandId, requested } + : current); + return accepted; + } catch (error) { + setPendingModel(null); + setSubmissionError("无法提交 Model 设置,请重试。"); + throw error; + } + } + + async function setPermissionMode( + mode: SelectablePermissionMode, + ): Promise<{ commandId: string }> { + props.permissionFailure?.dismiss?.(); + setSubmissionError(null); + setPendingPermission({ commandId: null, requested: mode }); + try { + const accepted = await props.setPermissionMode(mode); + setPendingPermission((current) => + current?.requested === mode + ? { commandId: accepted.commandId, requested: mode } + : current); + return accepted; + } catch (error) { + setPendingPermission(null); + setSubmissionError("无法提交 Permission Mode 设置,请重试。"); + throw error; + } + } + + return ( +
+
+ + + {props.context === null ? null : ( + + {props.context.label} + + )} +
+ {submissionError === null ? null : ( +

{submissionError}

+ )} + {props.modelFailure === undefined && + props.permissionFailure === undefined ? null : ( +

+ {props.modelFailure?.message ?? props.permissionFailure?.message} + {(props.modelFailure ?? props.permissionFailure)?.dismiss === undefined + ? null + : ( + + )} +

+ )} +
+ ); +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/composer-drafts.ts b/typescript/web-ui-showcase/src/client/features/conversation/composer-drafts.ts new file mode 100644 index 0000000..007f779 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/composer-drafts.ts @@ -0,0 +1,23 @@ +/** Draft storage shared by the resident Home and Session Composer variants. */ +export class ComposerDrafts { + readonly #values = new Map(); + + read(key: string): string { + return this.#values.get(key) ?? ""; + } + + write(key: string, value: string): void { + this.#values.set(key, value); + } + + clear(key: string): void { + this.#values.delete(key); + } + + retain(keys: readonly string[]): void { + const retained = new Set(keys); + for (const key of this.#values.keys()) { + if (!retained.has(key)) this.#values.delete(key); + } + } +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestion-list.tsx b/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestion-list.tsx new file mode 100644 index 0000000..eeb6cfc --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestion-list.tsx @@ -0,0 +1,66 @@ +import { useLayoutEffect, useRef } from "react"; +import type { ComposerSuggestion } from "./composer-suggestions.js"; + +export function suggestionOptionId(listboxId: string, index: number): string { + return `${listboxId}-option-${index}`; +} + +export function ComposerSuggestionList(props: { + id: string; + label: string; + items: ComposerSuggestion[]; + activeIndex: number; + statusMessage?: string; + truncated?: boolean; + truncatedMessage?: string; + onHover: (index: number) => void; + onSelect: (suggestion: ComposerSuggestion) => void; +}): JSX.Element { + const optionRefs = useRef(new Map()); + const activeId = props.items[props.activeIndex]?.id; + useLayoutEffect(() => { + if (activeId === undefined) return; + optionRefs.current.get(activeId)?.scrollIntoView?.({ block: "nearest" }); + }, [activeId]); + + return ( +
+ {props.items.map((item, index) => ( + + ))} + {props.statusMessage === undefined ? null : ( +

{props.statusMessage}

+ )} + {props.truncated === true && props.truncatedMessage !== undefined ? ( + {props.truncatedMessage} + ) : null} +
+ ); +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestions.ts b/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestions.ts new file mode 100644 index 0000000..fd3eb9d --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/composer-suggestions.ts @@ -0,0 +1,168 @@ +import type { ComposerCommandView } from "../../../shared/model.js"; +import type { WorkspaceFileItem } from "../../../shared/workspace-files.js"; + +export type ActiveSuggestionQuery = + | { kind: "command"; start: number; end: number; query: string } + | { + kind: "file"; + start: number; + end: number; + query: string; + quoted: boolean; + }; + +export type ComposerSuggestion = + | { kind: "command"; id: string; command: ComposerCommandView } + | ({ kind: "file"; id: string } & WorkspaceFileItem); + +export type ModelOption = { value: string; label: string }; + +/** Returns distinct, non-empty suggestions in SDK order. */ +export function normalizePromptSuggestions( + suggestions: readonly string[], +): string[] { + const normalized = new Set(); + for (const suggestion of suggestions) { + for (const line of suggestion.split(/\r?\n/u)) { + const value = line.trim(); + if (value.length > 0) normalized.add(value); + } + } + return [...normalized]; +} + +export function parseSuggestionQuery( + text: string, + cursor: number, +): ActiveSuggestionQuery | null { + if (cursor < 0 || cursor > text.length) return null; + const prefix = text.slice(0, cursor); + const firstNonWhitespace = text.search(/\S/u); + if ( + firstNonWhitespace >= 0 && + firstNonWhitespace < cursor && + text[firstNonWhitespace] === "/" + ) { + const commandToken = text.slice(firstNonWhitespace + 1, cursor); + if (!/\s/u.test(commandToken)) { + return { + kind: "command", + start: firstNonWhitespace, + end: cursor, + query: commandToken, + }; + } + } + + let quotedStart = prefix.lastIndexOf('@"'); + while (quotedStart >= 0) { + const startsToken = + quotedStart === 0 || /\s/u.test(prefix[quotedStart - 1] ?? ""); + const quotedQuery = prefix.slice(quotedStart + 2); + if (startsToken && !quotedQuery.includes('"')) { + return { + kind: "file", + start: quotedStart, + end: cursor, + query: quotedQuery, + quoted: true, + }; + } + quotedStart = prefix.lastIndexOf('@"', quotedStart - 1); + } + + let tokenStart = cursor; + while (tokenStart > 0 && !/\s/u.test(text[tokenStart - 1] ?? "")) { + tokenStart -= 1; + } + const token = text.slice(tokenStart, cursor); + if (!token.startsWith("@") || token.includes('"')) return null; + return { + kind: "file", + start: tokenStart, + end: cursor, + query: token.slice(1), + quoted: false, + }; +} + +export function filterCommandSuggestions( + commands: ComposerCommandView[], + query: string, +): ComposerSuggestion[] { + const normalizedQuery = query.toLocaleLowerCase(); + return commands + .filter((command) => + command.name.toLocaleLowerCase().startsWith(normalizedQuery), + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((command) => ({ + kind: "command" as const, + id: `command:${command.name}`, + command, + })); +} + +export function readModelOptions( + models: readonly Record[], +): ModelOption[] { + const options = new Map(); + for (const model of models) { + const value = ["value", "id", "model", "name"] + .map((key) => model[key]) + .find( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ); + if (value === undefined || options.has(value)) continue; + const label = ["displayName", "name", "label", "id", "model", "value"] + .map((key) => model[key]) + .find( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ); + options.set(value, { value, label: label ?? value }); + } + return [...options.values()].sort((left, right) => + left.label.localeCompare(right.label), + ); +} + +export function resolveCompletedCommand( + text: string, + commands: readonly ComposerCommandView[], +): { command: ComposerCommandView; argument: string } | undefined { + const match = /^\/([^\s]+)(?:\s+(.*))?$/u.exec(text.trim()); + if (match === null) return undefined; + const command = commands.find((candidate) => candidate.name === match[1]); + return command === undefined + ? undefined + : { command, argument: (match[2] ?? "").trim() }; +} + +export function applySuggestion( + text: string, + _cursor: number, + query: ActiveSuggestionQuery, + suggestion: ComposerSuggestion, +): { text: string; cursor: number } { + let replacement: string; + switch (suggestion.kind) { + case "command": + replacement = `/${suggestion.command.name}${ + suggestion.command.argumentHint.trim().length > 0 ? " " : "" + }`; + break; + case "file": + replacement = `${ + /\s/u.test(suggestion.mention) + ? `@"${suggestion.mention}"` + : `@${suggestion.mention}` + } `; + break; + } + return { + text: `${text.slice(0, query.start)}${replacement}${text.slice(query.end)}`, + cursor: query.start + replacement.length, + }; +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/context-summary.ts b/typescript/web-ui-showcase/src/client/features/conversation/context-summary.ts new file mode 100644 index 0000000..5084fd7 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/context-summary.ts @@ -0,0 +1,64 @@ +export type ContextSummary = { + label: string; + title: string; +}; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function numberAt( + value: Record | undefined, + key: string, +): number | undefined { + const candidate = value?.[key]; + return typeof candidate === "number" && Number.isFinite(candidate) + ? candidate + : undefined; +} + +function percentageLabel(value: number): string { + const percentage = value >= 0 && value <= 1 ? value * 100 : value; + return `${Math.round(Math.min(100, Math.max(0, percentage)))}%`; +} + +function formatTokens(value: number): string { + return Math.max(0, Math.round(value)).toLocaleString("en-US"); +} + +/** Summarizes old and current SDK Context responses for the Composer. */ +export function readContextSummary( + status: "loading" | "ready" | "unsupported" | undefined, + context: Record | undefined, +): ContextSummary | null { + if (status !== "ready") return null; + + const contextWindow = record(context?.contextWindow); + const percentage = + numberAt(context, "percentage") ?? + numberAt(context, "percent") ?? + numberAt(context, "usedPercentage") ?? + numberAt(contextWindow, "usedPercentage"); + const usedTokens = + numberAt(context, "totalTokens") ?? + numberAt(context, "usedTokens") ?? + numberAt(contextWindow, "usedTokens"); + const maxTokens = + numberAt(context, "maxTokens") ?? + numberAt(context, "sizeTokens") ?? + numberAt(contextWindow, "sizeTokens"); + + if (maxTokens === undefined || maxTokens <= 0 || percentage === undefined) { + return null; + } + + return { + label: `Context ${percentageLabel(percentage)}`, + title: + usedTokens === undefined + ? `当前 Session 的 Context 上限为 ${formatTokens(maxTokens)} tokens。` + : `已使用 ${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} tokens`, + }; +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/conversation-panel.tsx b/typescript/web-ui-showcase/src/client/features/conversation/conversation-panel.tsx new file mode 100644 index 0000000..1016577 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/conversation-panel.tsx @@ -0,0 +1,59 @@ +import type { InteractionResponse, SendMessageInput } from "../../../shared/commands.js"; +import type { WorkspaceFileSearchResult } from "../../../shared/workspace-files.js"; +import type { SubagentTranscriptResponse } from "../../../shared/subagents.js"; +import { useAppState, useAppStore } from "../../store/store-context.js"; +import { InteractionCard } from "../interactions/interaction-card.js"; +import { MessageList } from "./message-list.js"; + +type Accepted = { commandId: string }; + +export type ConversationApi = { + sendMessage(sessionId: string, input: SendMessageInput): Promise; + cancelMessage(sessionId: string, uuid: string): Promise; + respondToInteraction(id: string, response: InteractionResponse): Promise; + stopTask(sessionId: string, taskId: string): Promise; + backgroundTasks(sessionId: string, toolUseId?: string): Promise; + interruptSession(sessionId: string): Promise; + refreshContext(sessionId: string): Promise; + searchWorkspaceFiles( + sessionId: string, + query: string, + ): Promise; + getSubagentTranscript( + sessionId: string, + toolUseId: string, + signal?: AbortSignal, + ): Promise; +}; + +export function ConversationPanel(props: { + api: ConversationApi; +}): JSX.Element { + const state = useAppState(); + const store = useAppStore(); + const sessionId = state.selectedSessionId; + const session = sessionId === null ? undefined : state.sessions[sessionId]; + if (session === undefined) { + return <>; + } + const messages = state.messages[session.id] ?? []; + const interactions = state.interactionIds.flatMap((id) => { + const interaction = state.interactions[id]; + return interaction?.sessionId === session.id ? [interaction] : []; + }); + return ( + store.openDetails({ + kind: "subagent", + sessionId: session.id, + toolUseId: item.toolUseId, + })} + > +
+ {interactions.map((interaction) => props.api.respondToInteraction(id, response)} onSelect={(interactionId) => store.openDetails({ kind: "approval", sessionId: session.id, interactionId })} />)} +
+
+ ); +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/conversation-root.tsx b/typescript/web-ui-showcase/src/client/features/conversation/conversation-root.tsx new file mode 100644 index 0000000..63c79c2 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/conversation-root.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useState } from "react"; +import type { + SelectablePermissionMode, + SessionStarted, + StartSessionCommand, +} from "../../../shared/commands.js"; +import type { WorkspaceView } from "../../../shared/model.js"; +import { copy } from "../../i18n/zh-cn.js"; +import { useAppState, useAppStore } from "../../store/store-context.js"; +import { findCommandFailure } from "../../store/command-ownership.js"; +import { ComposerDrafts } from "./composer-drafts.js"; +import { CommandFailureNotice } from "../errors/command-failure-notice.js"; +import { + ConversationPanel, + type ConversationApi, +} from "./conversation-panel.js"; +import { PromptComposer, type ComposerTarget } from "./prompt-composer.js"; + +type ConversationRootApi = ConversationApi & { + startSession(input: StartSessionCommand): Promise; + setModel(sessionId: string, model?: string): Promise<{ commandId: string }>; + setPermissionMode( + sessionId: string, + mode: SelectablePermissionMode, + ): Promise<{ commandId: string }>; +}; + +export function ConversationRoot(props: { + api: ConversationRootApi; + workspaces: WorkspaceView[]; + autoResumingSessionIds: ReadonlySet; + ensureFailedSessionIds: ReadonlySet; + onAccepted: (label: string, command: { commandId: string }) => void; + realtime: { selectSession(sessionId: string | null): void }; +}): JSX.Element { + const state = useAppState(); + const store = useAppStore(); + const [drafts] = useState(() => new ComposerDrafts()); + useEffect(() => { + drafts.retain(["home", ...state.sessionIds]); + }, [drafts, state.sessionIds]); + const sessionId = state.selectedSessionId; + const session = sessionId === null ? undefined : state.sessions[sessionId]; + const workspace = [...props.workspaces].sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + )[0]; + const sessionAutoResuming = + session === undefined + ? false + : props.autoResumingSessionIds.has(session.id); + const settling = + sessionId !== null && (session === undefined || sessionAutoResuming); + const modelFailure = session === undefined + ? undefined + : findCommandFailure(state, { + surface: "runtime", + control: "model", + sessionId: session.id, + }); + const permissionFailure = session === undefined + ? undefined + : findCommandFailure(state, { + surface: "runtime", + control: "permission", + sessionId: session.id, + }); + const availabilityMessage = + session?.phase === "restorable" + ? props.ensureFailedSessionIds.has(session.id) + ? copy.session.ensureFailed + : session.failure?.message + : undefined; + const phase = + sessionId === null + ? "hero" + : session === undefined || sessionAutoResuming + ? "settling" + : availabilityMessage !== undefined + ? "unavailable" + : "active"; + const queued = + session === undefined + ? [] + : state.queuedInputIds.flatMap((id) => { + const item = state.queuedInputs[id]; + return item?.sessionId === session.id ? [item] : []; + }); + const searchWorkspaceFiles = useCallback( + (sessionId: string, query: string) => + props.api.searchWorkspaceFiles(sessionId, query), + [props.api], + ); + const target: ComposerTarget = + session === undefined + ? { + kind: "home", + workspaceId: workspace?.id ?? null, + start: async (text) => { + const started = await props.api.startSession({ + ...(workspace === undefined ? {} : { workspaceId: workspace.id }), + text, + }); + props.realtime.selectSession(started.sessionId); + }, + } + : { + kind: "session", + session, + ...(state.runtime[session.id] === undefined + ? {} + : { runtime: state.runtime[session.id] }), + send: async (text) => { + const accepted = await props.api.sendMessage(session.id, { text }); + store.registerCommand(accepted.commandId, { + surface: "conversation", + control: "send", + sessionId: session.id, + }); + }, + stop: async () => { + const accepted = await props.api.interruptSession(session.id); + store.registerCommand(accepted.commandId, { + surface: "conversation", + control: "stop", + sessionId: session.id, + }); + }, + setModel: async (model) => { + const accepted = await props.api.setModel(session.id, model); + store.registerCommand(accepted.commandId, { + surface: "runtime", + control: "model", + sessionId: session.id, + }); + return accepted; + }, + setPermissionMode: async (mode) => { + const accepted = await props.api.setPermissionMode(session.id, mode); + store.registerCommand(accepted.commandId, { + surface: "runtime", + control: "permission", + sessionId: session.id, + }); + return accepted; + }, + openMcp: () => store.openRuntimeDialog("mcp"), + ...(modelFailure === undefined + ? {} + : { + modelFailure: { + ...(modelFailure.commandId === undefined + ? {} + : { + commandId: modelFailure.commandId, + dismiss: () => { + if (modelFailure.commandId !== undefined) { + store.dismissCommandFailure(modelFailure.commandId); + } + }, + }), + message: modelFailure.error.message, + }, + }), + ...(permissionFailure === undefined + ? {} + : { + permissionFailure: { + ...(permissionFailure.commandId === undefined + ? {} + : { + commandId: permissionFailure.commandId, + dismiss: () => { + if (permissionFailure.commandId !== undefined) { + store.dismissCommandFailure( + permissionFailure.commandId, + ); + } + }, + }), + message: permissionFailure.error.message, + }, + }), + refreshContext: async () => { + const accepted = await props.api.refreshContext(session.id); + store.registerCommand(accepted.commandId, { + surface: "conversation", + control: "context", + sessionId: session.id, + }); + props.onAccepted("Context 刷新请求已接受", accepted); + }, + }; + + return ( +
+ {phase === "hero" ? ( +
+
Q
+

{copy.home.title}

+

{copy.home.subtitle}

+

+ {workspace === undefined + ? copy.home.noWorkspace + : `${copy.home.recentWorkspace}:${workspace.displayName}`} +

+
+ ) : phase === "settling" ? ( +
+

{copy.composer.restoringPlaceholder}

+
+ ) : phase === "unavailable" ? ( +
+

{availabilityMessage ?? copy.common.unavailable}

+
+ ) : ( + + )} + {session === undefined ? null : ( + ({ + surface: "conversation" as const, + control, + sessionId: session.id, + }))} /> + )} + { + const accepted = await props.api.cancelMessage( + session.id, + uuid, + ); + store.registerCommand(accepted.commandId, { + surface: "conversation", + control: "cancel", + sessionId: session.id, + }); + return accepted; + }, + })} + searchWorkspaceFiles={searchWorkspaceFiles} + /> +
+ ); +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/message-item.tsx b/typescript/web-ui-showcase/src/client/features/conversation/message-item.tsx new file mode 100644 index 0000000..421abc0 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/message-item.tsx @@ -0,0 +1,43 @@ +import type { ConversationItem } from "../../../shared/model.js"; +import { copy } from "../../i18n/zh-cn.js"; +import { ToolCard } from "./tool-card.js"; + +function assertNever(value: never): never { + throw new Error(`Unknown conversation item: ${JSON.stringify(value)}`); +} + +export function MessageItem(props: { + item: ConversationItem; + onSelectAgent?: ( + item: Extract, + ) => void; +}): JSX.Element | null { + const { item } = props; + switch (item.kind) { + case "user": + return

{item.text}

; + case "assistant": { + const streaming = item.status === "streaming" || item.streaming === true; + return

{item.text}

{streaming ? : null}{item.status === "interrupted" ? {copy.conversation.interrupted} : null}{item.status === "failed" ? {copy.conversation.failed} : null}
; + } + case "tool": + return ( + + ); + case "result": + return null; + case "progress": + return
{item.label}{item.detail === undefined ? null :

{item.detail}

}
; + case "error": + return
{item.error.code}

{item.error.message}

; + case "raw": + return null; + default: + return assertNever(item); + } +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/message-list.tsx b/typescript/web-ui-showcase/src/client/features/conversation/message-list.tsx new file mode 100644 index 0000000..dd023ef --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/message-list.tsx @@ -0,0 +1,51 @@ +import { useLayoutEffect, useRef, type ReactNode } from "react"; +import type { ConversationItem } from "../../../shared/model.js"; +import { EventBoundary } from "../errors/event-boundary.js"; +import { MessageItem } from "./message-item.js"; + +export function MessageList(props: { + sessionId: string; + items: ConversationItem[]; + children?: ReactNode; + onSelectAgent?: ( + item: Extract, + ) => void; +}): JSX.Element { + const list = useRef(null); + const followLatest = useRef(true); + const previousSessionId = useRef(props.sessionId); + useLayoutEffect(() => { + if (previousSessionId.current !== props.sessionId) { + previousSessionId.current = props.sessionId; + followLatest.current = true; + } + const element = list.current; + if (element !== null && followLatest.current) { + element.scrollTop = element.scrollHeight; + } + }, [props.children, props.items, props.sessionId]); + return ( +
{ + const element = event.currentTarget; + followLatest.current = + element.scrollHeight - element.scrollTop - element.clientHeight <= 80; + }} + > + {props.items.map((item) => ( + + + + ))} + {props.children} +
+ ); +} diff --git a/typescript/web-ui-showcase/src/client/features/conversation/prompt-composer.tsx b/typescript/web-ui-showcase/src/client/features/conversation/prompt-composer.tsx new file mode 100644 index 0000000..4536180 --- /dev/null +++ b/typescript/web-ui-showcase/src/client/features/conversation/prompt-composer.tsx @@ -0,0 +1,552 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import type { + SessionRuntimeView, + QueuedInputView, + SessionView, +} from "../../../shared/model.js"; +import type { SelectablePermissionMode } from "../../../shared/commands.js"; +import type { WorkspaceFileSearchResult } from "../../../shared/workspace-files.js"; +import { copy, queuedStateLabel } from "../../i18n/zh-cn.js"; +import { + ComposerSuggestionList, + suggestionOptionId, +} from "./composer-suggestion-list.js"; +import { + applySuggestion, + filterCommandSuggestions, + normalizePromptSuggestions, + parseSuggestionQuery, + resolveCompletedCommand, + type ActiveSuggestionQuery, + type ComposerSuggestion, +} from "./composer-suggestions.js"; +import { + ComposerControlMenu, + runtimeControlReason, + type RuntimeControlFailure, + type RuntimeControl, +} from "./composer-control-menu.js"; +import { ComposerDrafts } from "./composer-drafts.js"; +import { readContextSummary } from "./context-summary.js"; + +type Accepted = { commandId: string }; +type FileSuggestionState = + | { status: "idle"; items: []; truncated: false } + | { status: "loading"; items: []; truncated: false } + | { status: "failed"; items: []; truncated: false } + | { + status: "ready"; + items: WorkspaceFileSearchResult["items"]; + truncated: boolean; + }; + +function suggestionQueryKey(query: ActiveSuggestionQuery | null): string | null { + return query === null + ? null + : `${query.kind}:${query.start}:${query.end}:${query.query}`; +} + +export type ComposerTarget = + | { + kind: "home"; + workspaceId: string | null; + start(text: string): Promise; + } + | { + kind: "session"; + session: SessionView; + runtime?: SessionRuntimeView; + send(text: string): Promise; + stop(): Promise; + setModel(model?: string): Promise; + setPermissionMode( + mode: SelectablePermissionMode, + ): Promise; + openMcp(): void; + refreshContext(): Promise; + modelFailure?: RuntimeControlFailure; + permissionFailure?: RuntimeControlFailure; + }; + +type CommonComposerProps = { + queued?: QueuedInputView[]; + cancel?: (uuid: string) => Promise; + searchWorkspaceFiles?: ( + sessionId: string, + query: string, + ) => Promise; +}; + +type PromptComposerProps = CommonComposerProps & { + target: ComposerTarget; + drafts: ComposerDrafts; + autoResuming?: boolean; + disabledReason?: string; +}; + +export function PromptComposer(props: PromptComposerProps): JSX.Element { + const { drafts, target } = props; + const session = target.kind === "session" ? target.session : undefined; + const runtime = target.kind === "session" ? target.runtime : undefined; + const draftKey = target.kind === "home" ? "home" : target.session.id; + const draft = drafts.read(draftKey); + const [, setDraftRevision] = useState(0); + const [cursor, setCursor] = useState(0); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [dismissedQueryKey, setDismissedQueryKey] = useState(null); + const [fileState, setFileState] = useState({ + status: "idle", + items: [], + truncated: false, + }); + const textarea = useRef(null); + const modelControl = useRef(null); + const permissionControl = useRef(null); + const restoreCaret = useRef(false); + const requestSequence = useRef(0); + const enabled = + !props.autoResuming && + props.disabledReason === undefined && + (session === undefined || + session.phase === "idle" || + session.phase === "running"); + const activeQuery = parseSuggestionQuery(draft, cursor); + const queryKey = suggestionQueryKey(activeQuery); + const composerCommands = runtime?.composerCommands ?? []; + const commandSuggestions = useMemo( + () => + activeQuery?.kind === "command" + ? filterCommandSuggestions( + composerCommands, + activeQuery.query, + ) + : [], + [activeQuery?.kind, activeQuery?.query, composerCommands], + ); + const fileSuggestions = useMemo( + () => + activeQuery?.kind === "file" && fileState.status === "ready" + ? fileState.items.map((item) => ({ + kind: "file" as const, + id: `file:${item.source}:${item.mention}`, + ...item, + })) + : [], + [activeQuery?.kind, fileState], + ); + const suggestions = + activeQuery?.kind === "file" + ? fileSuggestions + : commandSuggestions; + const popupOpen = + activeQuery !== null && dismissedQueryKey !== queryKey; + const listboxId = `composer-suggestions-${draftKey}`; + const visibleActiveIndex = activeIndex < suggestions.length ? activeIndex : 0; + const activeSuggestion = suggestions[visibleActiveIndex]; + const suggestionIdentity = suggestions + .map((suggestion) => suggestion.id) + .join("\u0000"); + const contextSummary = readContextSummary( + runtime?.contextStatus, + runtime?.context, + ); + const promptSuggestions = normalizePromptSuggestions( + runtime?.promptSuggestions ?? [], + ); + + useEffect(() => setActiveIndex(0), [queryKey, suggestionIdentity]); + + useEffect(() => { + requestSequence.current += 1; + setCursor(drafts.read(draftKey).length); + setFileState({ status: "idle", items: [], truncated: false }); + setDismissedQueryKey(null); + }, [draftKey, drafts]); + + useEffect(() => { + if (activeQuery?.kind !== "file") { + requestSequence.current += 1; + setFileState({ status: "idle", items: [], truncated: false }); + return; + } + const sessionId = target.kind === "session" ? target.session.id : null; + if (props.searchWorkspaceFiles === undefined || sessionId === null) { + setFileState({ status: "failed", items: [], truncated: false }); + return; + } + const sequence = ++requestSequence.current; + let active = true; + setFileState({ status: "loading", items: [], truncated: false }); + void props + .searchWorkspaceFiles(sessionId, activeQuery.query) + .then( + (result) => { + if (!active || requestSequence.current !== sequence) return; + setFileState({ status: "ready", ...result }); + }, + () => { + if (!active || requestSequence.current !== sequence) return; + setFileState({ status: "failed", items: [], truncated: false }); + }, + ); + return () => { + active = false; + }; + }, [ + activeQuery?.kind, + activeQuery?.query, + props.searchWorkspaceFiles, + draftKey, + target.kind, + target.kind === "home" ? null : target.session.id, + ]); + + useLayoutEffect(() => { + if (!restoreCaret.current) return; + textarea.current?.focus(); + textarea.current?.setSelectionRange(cursor, cursor); + const frame = window.requestAnimationFrame(() => { + restoreCaret.current = false; + }); + return () => window.cancelAnimationFrame(frame); + }, [cursor, draft]); + + function writeDraft(value: string): void { + drafts.write(draftKey, value); + setDraftRevision((revision) => revision + 1); + } + + function clearDraft(key = draftKey): void { + drafts.clear(key); + setDraftRevision((revision) => revision + 1); + setCursor(0); + setDismissedQueryKey(null); + } + + function checkRuntimeControl(control: RuntimeControl): boolean { + if (target.kind !== "session") return false; + const reason = runtimeControlReason(runtime, control); + if (reason !== undefined) { + setError(reason); + return false; + } + setError(null); + return true; + } + + function focusRuntimeControl( + control: "model" | "permission", + ): boolean { + if (!checkRuntimeControl(control)) return false; + (control === "model" ? modelControl : permissionControl).current?.focus(); + return true; + } + + function openMcp(): boolean { + if (target.kind !== "session" || !checkRuntimeControl("mcp")) return false; + target.openMcp(); + return true; + } + + async function submitDraft(): Promise { + const text = draft.trim(); + if (!enabled || text.length === 0) return; + const submittedKey = draftKey; + try { + if (target.kind === "home") { + await target.start(text); + setError(null); + clearDraft(submittedKey); + return; + } + const completed = resolveCompletedCommand(text, composerCommands); + if (completed !== undefined) { + switch (completed.command.execution) { + case "sdk-input": + await target.send(text); + setError(null); + clearDraft(submittedKey); + return; + case "model-control": + if (focusRuntimeControl("model")) clearDraft(submittedKey); + return; + case "permission-control": + if (focusRuntimeControl("permission")) clearDraft(submittedKey); + return; + case "mcp-control": + if (openMcp()) clearDraft(submittedKey); + return; + case "context-control": { + if (completed.argument.length > 0) { + setError("/context 不接受参数。"); + return; + } + await target.refreshContext(); + setError(null); + clearDraft(submittedKey); + return; + } + } + } + await target.send(text); + setError(null); + clearDraft(submittedKey); + } catch { + setError("命令请求未能提交,请重试。"); + } + } + + function submit(event: FormEvent): void { + event.preventDefault(); + void submitDraft(); + } + + function chooseSuggestion(suggestion: ComposerSuggestion): void { + if (activeQuery === null) return; + if (suggestion.kind === "command" && target.kind === "session") { + const execution = suggestion.command.execution; + if ( + execution === "model-control" || + execution === "permission-control" || + execution === "mcp-control" + ) { + const opened = + execution === "model-control" + ? focusRuntimeControl("model") + : execution === "permission-control" + ? focusRuntimeControl("permission") + : openMcp(); + if (opened) clearDraft(); + return; + } + } + const next = applySuggestion(draft, cursor, activeQuery, suggestion); + restoreCaret.current = true; + writeDraft(next.text); + setCursor(next.cursor); + if ( + suggestion.kind === "command" && + suggestion.command.execution !== "model-control" && + suggestion.command.execution !== "permission-control" && + suggestion.command.execution !== "mcp-control" + ) { + setDismissedQueryKey( + suggestionQueryKey(parseSuggestionQuery(next.text, next.cursor)), + ); + } else { + setDismissedQueryKey(null); + } + } + + function onKeyDown(event: ReactKeyboardEvent): void { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter" && event.shiftKey) return; + if ( + popupOpen && + activeSuggestion !== undefined && + (event.key === "Enter" || event.key === "Tab") + ) { + event.preventDefault(); + chooseSuggestion(activeSuggestion); + return; + } + if ( + popupOpen && + suggestions.length > 0 && + (event.key === "ArrowDown" || event.key === "ArrowUp") + ) { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + setActiveIndex( + (index) => + (index + direction + suggestions.length) % suggestions.length, + ); + return; + } + if (popupOpen && event.key === "Escape") { + event.preventDefault(); + setDismissedQueryKey(queryKey); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + void submitDraft(); + } + } + + return ( +
+ {(props.queued ?? []).length === 0 ? null : ( +
+ {(props.queued ?? []).map((item) => ( + + {item.uuid.slice(0, 8)}{" "} + {queuedStateLabel(item.state)} + + + ))} +
+ )} + {error === null ? null :

{error}

} + {draft.length > 0 || promptSuggestions.length === 0 ? null : ( +
+ {promptSuggestions.map((suggestion) => ( + + ))} +
+ )} + {popupOpen ? ( + + ) : null} +
+