nastechai-desktop 18.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +11 -0
- package/DESIGN.md +167 -0
- package/README.md +141 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/components.json +21 -0
- package/electron/backend-env.cjs +112 -0
- package/electron/backend-env.test.cjs +111 -0
- package/electron/backend-probes.cjs +106 -0
- package/electron/backend-probes.test.cjs +82 -0
- package/electron/backend-ready.cjs +66 -0
- package/electron/bootstrap-platform.cjs +91 -0
- package/electron/bootstrap-platform.test.cjs +111 -0
- package/electron/bootstrap-runner.cjs +720 -0
- package/electron/bootstrap-runner.test.cjs +138 -0
- package/electron/connection-config.cjs +254 -0
- package/electron/connection-config.test.cjs +329 -0
- package/electron/dashboard-token.cjs +99 -0
- package/electron/dashboard-token.test.cjs +142 -0
- package/electron/desktop-uninstall.cjs +232 -0
- package/electron/desktop-uninstall.test.cjs +246 -0
- package/electron/entitlements.mac.inherit.plist +14 -0
- package/electron/entitlements.mac.plist +14 -0
- package/electron/fs-read-dir.cjs +109 -0
- package/electron/fs-read-dir.test.cjs +364 -0
- package/electron/gateway-ws-probe.cjs +188 -0
- package/electron/gateway-ws-probe.test.cjs +122 -0
- package/electron/git-root.cjs +54 -0
- package/electron/git-root.test.cjs +40 -0
- package/electron/git-worktrees.cjs +174 -0
- package/electron/hardening.cjs +184 -0
- package/electron/hardening.test.cjs +116 -0
- package/electron/main.cjs +5762 -0
- package/electron/oauth-net-request.cjs +20 -0
- package/electron/oauth-net-request.test.cjs +34 -0
- package/electron/preload.cjs +135 -0
- package/electron/session-windows.cjs +99 -0
- package/electron/session-windows.test.cjs +177 -0
- package/electron/update-remote.cjs +56 -0
- package/electron/update-remote.test.cjs +78 -0
- package/electron/vscode-marketplace.cjs +331 -0
- package/electron/vscode-marketplace.test.cjs +113 -0
- package/electron/windows-child-process.test.cjs +57 -0
- package/electron/windows-user-env.cjs +76 -0
- package/electron/windows-user-env.test.cjs +90 -0
- package/electron/workspace-cwd.cjs +38 -0
- package/electron/workspace-cwd.test.cjs +45 -0
- package/eslint.config.mjs +122 -0
- package/index.html +17 -0
- package/package.json +254 -0
- package/pr-assets/session-source-folders.png +0 -0
- package/preview-demo.html +65 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/ds-assets/filler-bg0.jpg +0 -0
- package/public/nastech-frames/nastech-frame-0.png +0 -0
- package/public/nastech-frames/nastech-frame-1.png +0 -0
- package/public/nastech-frames/nastech-frame-2.png +0 -0
- package/public/nastech-frames/nastech-frame-3.png +0 -0
- package/public/nastech-frames/nastech-frame-4.png +0 -0
- package/public/nastech-frames/nastech-frame-5.png +0 -0
- package/public/nastech-frames/nastech-frame-6.png +0 -0
- package/public/nastech-frames/nastech-frame-7.png +0 -0
- package/public/nastech-girl.jpg +0 -0
- package/public/nastech-sprite.png +0 -0
- package/public/nastech.png +0 -0
- package/scripts/after-pack.cjs +41 -0
- package/scripts/assert-dist-built.cjs +70 -0
- package/scripts/assert-dist-built.test.cjs +84 -0
- package/scripts/assert-root-install.cjs +13 -0
- package/scripts/before-build.cjs +11 -0
- package/scripts/before-pack.cjs +78 -0
- package/scripts/before-pack.test.cjs +53 -0
- package/scripts/click-session.mjs +51 -0
- package/scripts/dev-no-hmr.mjs +22 -0
- package/scripts/diag-jump.mjs +115 -0
- package/scripts/diag-scroll-reset.mjs +229 -0
- package/scripts/eval.mjs +21 -0
- package/scripts/leak-typing.mjs +222 -0
- package/scripts/measure-jump.mjs +108 -0
- package/scripts/measure-latency.mjs +184 -0
- package/scripts/measure-real-stream.mjs +252 -0
- package/scripts/measure-submit.mjs +179 -0
- package/scripts/measure-synthetic-stream.mjs +322 -0
- package/scripts/notarize-artifact.cjs +77 -0
- package/scripts/notarize.cjs +100 -0
- package/scripts/patch-electron-builder-mac-binary.cjs +59 -0
- package/scripts/probe-renderer.mjs +38 -0
- package/scripts/probe-thread.mjs +40 -0
- package/scripts/profile-long-stream.mjs +191 -0
- package/scripts/profile-real-stream.mjs +137 -0
- package/scripts/profile-synth-stream.mjs +103 -0
- package/scripts/profile-typing-lag.md +381 -0
- package/scripts/profile-typing.mjs +260 -0
- package/scripts/reload-renderer.mjs +25 -0
- package/scripts/reload.mjs +36 -0
- package/scripts/set-exe-identity.cjs +94 -0
- package/scripts/stage-native-deps.cjs +159 -0
- package/scripts/test-desktop.mjs +425 -0
- package/scripts/write-build-stamp.cjs +126 -0
- package/src/app/agents/index.tsx +398 -0
- package/src/app/artifacts/index.test.ts +62 -0
- package/src/app/artifacts/index.tsx +906 -0
- package/src/app/chat/chat-drop-overlay.tsx +48 -0
- package/src/app/chat/chat-swap-overlay.tsx +47 -0
- package/src/app/chat/composer/attachments.tsx +114 -0
- package/src/app/chat/composer/completion-drawer.tsx +63 -0
- package/src/app/chat/composer/context-menu.tsx +172 -0
- package/src/app/chat/composer/controls.tsx +289 -0
- package/src/app/chat/composer/drop-affordance.ts +2 -0
- package/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
- package/src/app/chat/composer/focus.ts +134 -0
- package/src/app/chat/composer/help-hint.tsx +59 -0
- package/src/app/chat/composer/hooks/use-at-completions.ts +141 -0
- package/src/app/chat/composer/hooks/use-live-completion-adapter.ts +119 -0
- package/src/app/chat/composer/hooks/use-mic-recorder.ts +291 -0
- package/src/app/chat/composer/hooks/use-slash-completions.ts +114 -0
- package/src/app/chat/composer/hooks/use-voice-conversation.ts +390 -0
- package/src/app/chat/composer/hooks/use-voice-recorder.ts +116 -0
- package/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
- package/src/app/chat/composer/index.tsx +1611 -0
- package/src/app/chat/composer/inline-refs.ts +138 -0
- package/src/app/chat/composer/model-pill.tsx +86 -0
- package/src/app/chat/composer/queue-panel.tsx +130 -0
- package/src/app/chat/composer/rich-editor.test.ts +18 -0
- package/src/app/chat/composer/rich-editor.ts +165 -0
- package/src/app/chat/composer/skin-slash-popover.tsx +61 -0
- package/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
- package/src/app/chat/composer/status-stack/index.tsx +202 -0
- package/src/app/chat/composer/status-stack/status-row.tsx +155 -0
- package/src/app/chat/composer/text-utils.test.ts +77 -0
- package/src/app/chat/composer/text-utils.ts +107 -0
- package/src/app/chat/composer/trigger-popover.test.tsx +42 -0
- package/src/app/chat/composer/trigger-popover.tsx +116 -0
- package/src/app/chat/composer/types.ts +64 -0
- package/src/app/chat/composer/url-dialog.tsx +82 -0
- package/src/app/chat/composer/voice-activity.tsx +252 -0
- package/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
- package/src/app/chat/hooks/use-composer-actions.ts +525 -0
- package/src/app/chat/hooks/use-file-drop-zone.ts +118 -0
- package/src/app/chat/index.tsx +390 -0
- package/src/app/chat/perf-probe.tsx +269 -0
- package/src/app/chat/right-rail/index.ts +1 -0
- package/src/app/chat/right-rail/preview-console-state.ts +82 -0
- package/src/app/chat/right-rail/preview-console.tsx +290 -0
- package/src/app/chat/right-rail/preview-file.tsx +559 -0
- package/src/app/chat/right-rail/preview-pane.test.tsx +43 -0
- package/src/app/chat/right-rail/preview-pane.tsx +657 -0
- package/src/app/chat/right-rail/preview.tsx +171 -0
- package/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
- package/src/app/chat/scroll-to-bottom-button.tsx +74 -0
- package/src/app/chat/sidebar/cron-jobs-section.tsx +325 -0
- package/src/app/chat/sidebar/index.tsx +1219 -0
- package/src/app/chat/sidebar/load-more-row.tsx +30 -0
- package/src/app/chat/sidebar/order.test.ts +21 -0
- package/src/app/chat/sidebar/order.ts +17 -0
- package/src/app/chat/sidebar/profile-switcher.tsx +516 -0
- package/src/app/chat/sidebar/session-actions-menu.tsx +264 -0
- package/src/app/chat/sidebar/session-row.tsx +257 -0
- package/src/app/chat/sidebar/virtual-session-list.tsx +154 -0
- package/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
- package/src/app/chat/sidebar/workspace-groups.ts +326 -0
- package/src/app/chat/thread-loading.test.ts +34 -0
- package/src/app/chat/thread-loading.ts +26 -0
- package/src/app/command-center/index.tsx +654 -0
- package/src/app/command-palette/index.tsx +513 -0
- package/src/app/command-palette/marketplace-theme-page.tsx +157 -0
- package/src/app/cron/index.tsx +942 -0
- package/src/app/cron/job-state.ts +29 -0
- package/src/app/desktop-controller.tsx +938 -0
- package/src/app/floating-hud.ts +22 -0
- package/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
- package/src/app/gateway/hooks/use-gateway-boot.ts +387 -0
- package/src/app/gateway/hooks/use-gateway-request.ts +138 -0
- package/src/app/hooks/use-keybinds.ts +186 -0
- package/src/app/hooks/use-refresh-hotkey.ts +45 -0
- package/src/app/hooks/use-route-enum-param.ts +38 -0
- package/src/app/index.tsx +1 -0
- package/src/app/layout-constants.ts +13 -0
- package/src/app/messaging/index.tsx +648 -0
- package/src/app/messaging/platform-icon.tsx +93 -0
- package/src/app/model-picker-overlay.tsx +42 -0
- package/src/app/model-visibility-overlay.tsx +31 -0
- package/src/app/overlays/overlay-chrome.tsx +66 -0
- package/src/app/overlays/overlay-search-input.tsx +33 -0
- package/src/app/overlays/overlay-split-layout.tsx +130 -0
- package/src/app/overlays/overlay-view.tsx +91 -0
- package/src/app/page-search-shell.tsx +75 -0
- package/src/app/profiles/create-profile-dialog.tsx +154 -0
- package/src/app/profiles/delete-profile-dialog.tsx +65 -0
- package/src/app/profiles/index.tsx +671 -0
- package/src/app/profiles/rename-profile-dialog.tsx +125 -0
- package/src/app/right-sidebar/files/dnd-manager.ts +27 -0
- package/src/app/right-sidebar/files/ipc.test.ts +100 -0
- package/src/app/right-sidebar/files/ipc.ts +161 -0
- package/src/app/right-sidebar/files/remote-picker.tsx +177 -0
- package/src/app/right-sidebar/files/tree.tsx +224 -0
- package/src/app/right-sidebar/files/use-project-tree.test.ts +190 -0
- package/src/app/right-sidebar/files/use-project-tree.ts +268 -0
- package/src/app/right-sidebar/index.test.tsx +75 -0
- package/src/app/right-sidebar/index.tsx +395 -0
- package/src/app/right-sidebar/store.ts +15 -0
- package/src/app/right-sidebar/terminal/buffer.ts +65 -0
- package/src/app/right-sidebar/terminal/index.tsx +98 -0
- package/src/app/right-sidebar/terminal/persistent.tsx +122 -0
- package/src/app/right-sidebar/terminal/selection.ts +75 -0
- package/src/app/right-sidebar/terminal/use-terminal-session.ts +504 -0
- package/src/app/routes.ts +88 -0
- package/src/app/session/hooks/use-context-suggestions.ts +58 -0
- package/src/app/session/hooks/use-cwd-actions.ts +109 -0
- package/src/app/session/hooks/use-message-stream.ts +957 -0
- package/src/app/session/hooks/use-model-controls.test.tsx +198 -0
- package/src/app/session/hooks/use-model-controls.ts +106 -0
- package/src/app/session/hooks/use-nastech-config.ts +74 -0
- package/src/app/session/hooks/use-preview-routing.test.tsx +168 -0
- package/src/app/session/hooks/use-preview-routing.ts +223 -0
- package/src/app/session/hooks/use-prompt-actions.test.tsx +316 -0
- package/src/app/session/hooks/use-prompt-actions.ts +1030 -0
- package/src/app/session/hooks/use-route-resume.test.tsx +136 -0
- package/src/app/session/hooks/use-route-resume.ts +115 -0
- package/src/app/session/hooks/use-session-actions.test.tsx +119 -0
- package/src/app/session/hooks/use-session-actions.ts +885 -0
- package/src/app/session/hooks/use-session-state-cache.test.tsx +118 -0
- package/src/app/session/hooks/use-session-state-cache.ts +191 -0
- package/src/app/session-picker-overlay.tsx +32 -0
- package/src/app/session-switcher.tsx +107 -0
- package/src/app/settings/about-settings.tsx +173 -0
- package/src/app/settings/appearance-settings.tsx +162 -0
- package/src/app/settings/config-settings.tsx +384 -0
- package/src/app/settings/constants.ts +545 -0
- package/src/app/settings/credential-key-ui.tsx +373 -0
- package/src/app/settings/env-credentials.tsx +198 -0
- package/src/app/settings/env-var-actions-menu.tsx +136 -0
- package/src/app/settings/field-copy.ts +56 -0
- package/src/app/settings/gateway-settings.tsx +620 -0
- package/src/app/settings/helpers.test.ts +138 -0
- package/src/app/settings/helpers.ts +151 -0
- package/src/app/settings/index.tsx +237 -0
- package/src/app/settings/keys-settings.tsx +96 -0
- package/src/app/settings/mcp-settings.tsx +271 -0
- package/src/app/settings/model-settings.test.tsx +157 -0
- package/src/app/settings/model-settings.tsx +559 -0
- package/src/app/settings/notifications-settings.tsx +150 -0
- package/src/app/settings/primitives.tsx +115 -0
- package/src/app/settings/providers-settings.test.tsx +100 -0
- package/src/app/settings/providers-settings.tsx +258 -0
- package/src/app/settings/sessions-settings.tsx +276 -0
- package/src/app/settings/toolset-config-panel.test.tsx +289 -0
- package/src/app/settings/toolset-config-panel.tsx +449 -0
- package/src/app/settings/types.ts +42 -0
- package/src/app/settings/uninstall-section.tsx +185 -0
- package/src/app/settings/use-deep-link-highlight.ts +60 -0
- package/src/app/shell/app-shell.tsx +167 -0
- package/src/app/shell/gateway-menu-panel.tsx +150 -0
- package/src/app/shell/hooks/use-overlay-routing.ts +71 -0
- package/src/app/shell/hooks/use-status-snapshot.ts +57 -0
- package/src/app/shell/hooks/use-statusbar-items.tsx +403 -0
- package/src/app/shell/keybind-panel.tsx +220 -0
- package/src/app/shell/model-edit-submenu.test.tsx +84 -0
- package/src/app/shell/model-edit-submenu.tsx +245 -0
- package/src/app/shell/model-menu-panel.tsx +295 -0
- package/src/app/shell/sidebar-label.tsx +22 -0
- package/src/app/shell/statusbar-controls.tsx +185 -0
- package/src/app/shell/titlebar-controls.tsx +244 -0
- package/src/app/shell/titlebar.test.ts +26 -0
- package/src/app/shell/titlebar.ts +45 -0
- package/src/app/shell/use-group-registry.ts +39 -0
- package/src/app/skills/index.test.tsx +103 -0
- package/src/app/skills/index.tsx +371 -0
- package/src/app/types.ts +99 -0
- package/src/app/updates-overlay.tsx +369 -0
- package/src/components/Backdrop.tsx +114 -0
- package/src/components/assistant-ui/ansi-text.tsx +34 -0
- package/src/components/assistant-ui/clarify-tool.tsx +281 -0
- package/src/components/assistant-ui/directive-text.test.ts +39 -0
- package/src/components/assistant-ui/directive-text.tsx +389 -0
- package/src/components/assistant-ui/markdown-text.test.ts +204 -0
- package/src/components/assistant-ui/markdown-text.tsx +497 -0
- package/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
- package/src/components/assistant-ui/message-render-boundary.tsx +48 -0
- package/src/components/assistant-ui/streaming.test.tsx +739 -0
- package/src/components/assistant-ui/thread-list.tsx +307 -0
- package/src/components/assistant-ui/thread-virtualizer.tsx +512 -0
- package/src/components/assistant-ui/thread.tsx +1474 -0
- package/src/components/assistant-ui/todo-tool.tsx +109 -0
- package/src/components/assistant-ui/tool-approval-group.test.tsx +158 -0
- package/src/components/assistant-ui/tool-approval.test.tsx +81 -0
- package/src/components/assistant-ui/tool-approval.tsx +209 -0
- package/src/components/assistant-ui/tool-fallback-model.test.ts +66 -0
- package/src/components/assistant-ui/tool-fallback-model.ts +1368 -0
- package/src/components/assistant-ui/tool-fallback.tsx +466 -0
- package/src/components/assistant-ui/tooltip-icon-button.tsx +33 -0
- package/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
- package/src/components/assistant-ui/user-message-text.tsx +150 -0
- package/src/components/boot-failure-overlay.tsx +246 -0
- package/src/components/boot-failure-reauth.test.ts +100 -0
- package/src/components/boot-failure-reauth.ts +81 -0
- package/src/components/brand-mark.tsx +19 -0
- package/src/components/chat/activity-timer-text.tsx +24 -0
- package/src/components/chat/activity-timer.test.tsx +43 -0
- package/src/components/chat/activity-timer.ts +64 -0
- package/src/components/chat/code-card.tsx +78 -0
- package/src/components/chat/compact-markdown.tsx +113 -0
- package/src/components/chat/composer-dock.ts +31 -0
- package/src/components/chat/diff-lines.tsx +54 -0
- package/src/components/chat/disclosure-row.tsx +63 -0
- package/src/components/chat/generated-image-context.tsx +19 -0
- package/src/components/chat/generated-image-result.tsx +174 -0
- package/src/components/chat/image-generation-placeholder.tsx +279 -0
- package/src/components/chat/intro-copy.jsonl +75 -0
- package/src/components/chat/intro.tsx +182 -0
- package/src/components/chat/preview-attachment.tsx +125 -0
- package/src/components/chat/shiki-highlighter.tsx +107 -0
- package/src/components/chat/status-row.tsx +70 -0
- package/src/components/chat/status-section.tsx +42 -0
- package/src/components/chat/terminal-output.tsx +50 -0
- package/src/components/chat/zoomable-image.tsx +177 -0
- package/src/components/desktop-install-overlay.tsx +595 -0
- package/src/components/desktop-onboarding-overlay.test.tsx +100 -0
- package/src/components/desktop-onboarding-overlay.tsx +1286 -0
- package/src/components/error-boundary.tsx +77 -0
- package/src/components/gateway-connecting-overlay.test.tsx +143 -0
- package/src/components/gateway-connecting-overlay.tsx +183 -0
- package/src/components/haptics-provider.tsx +19 -0
- package/src/components/language-switcher.test.tsx +53 -0
- package/src/components/language-switcher.tsx +175 -0
- package/src/components/model-picker.tsx +340 -0
- package/src/components/model-visibility-dialog.tsx +155 -0
- package/src/components/notifications.tsx +196 -0
- package/src/components/page-loader.tsx +34 -0
- package/src/components/pane-shell/context.ts +14 -0
- package/src/components/pane-shell/index.ts +4 -0
- package/src/components/pane-shell/pane-shell.test.tsx +333 -0
- package/src/components/pane-shell/pane-shell.tsx +330 -0
- package/src/components/prompt-overlays.tsx +234 -0
- package/src/components/session-picker.tsx +108 -0
- package/src/components/status-dot.tsx +26 -0
- package/src/components/ui/action-status.tsx +25 -0
- package/src/components/ui/alert.tsx +53 -0
- package/src/components/ui/badge.tsx +35 -0
- package/src/components/ui/braille-spinner.tsx +61 -0
- package/src/components/ui/button.tsx +81 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/codicon.tsx +20 -0
- package/src/components/ui/command.tsx +111 -0
- package/src/components/ui/confirm-dialog.tsx +109 -0
- package/src/components/ui/context-menu.tsx +141 -0
- package/src/components/ui/control.ts +25 -0
- package/src/components/ui/copy-button.test.tsx +36 -0
- package/src/components/ui/copy-button.tsx +229 -0
- package/src/components/ui/dialog.tsx +152 -0
- package/src/components/ui/disclosure-caret.tsx +20 -0
- package/src/components/ui/dropdown-menu.tsx +291 -0
- package/src/components/ui/error-state.tsx +50 -0
- package/src/components/ui/fade-text.tsx +110 -0
- package/src/components/ui/glyph-spinner.tsx +63 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/kbd.tsx +37 -0
- package/src/components/ui/loader.tsx +558 -0
- package/src/components/ui/log-view.tsx +17 -0
- package/src/components/ui/pagination.tsx +114 -0
- package/src/components/ui/popover.tsx +44 -0
- package/src/components/ui/scroll-area.tsx +43 -0
- package/src/components/ui/search-field.tsx +80 -0
- package/src/components/ui/segmented-control.tsx +51 -0
- package/src/components/ui/select.tsx +92 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +116 -0
- package/src/components/ui/sidebar.tsx +674 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/switch.tsx +49 -0
- package/src/components/ui/tabs.tsx +36 -0
- package/src/components/ui/text-tab.tsx +43 -0
- package/src/components/ui/textarea.tsx +11 -0
- package/src/components/ui/tool-icon.tsx +65 -0
- package/src/components/ui/tooltip.tsx +69 -0
- package/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/global.d.ts +457 -0
- package/src/hooks/use-image-download.ts +85 -0
- package/src/hooks/use-media-query.ts +24 -0
- package/src/hooks/use-mobile.ts +3 -0
- package/src/hooks/use-resize-observer.ts +38 -0
- package/src/hooks/use-worktree-info.ts +68 -0
- package/src/i18n/catalog.ts +12 -0
- package/src/i18n/context.test.tsx +232 -0
- package/src/i18n/context.tsx +183 -0
- package/src/i18n/define-locale.ts +41 -0
- package/src/i18n/en.ts +1779 -0
- package/src/i18n/index.ts +20 -0
- package/src/i18n/ja.ts +1890 -0
- package/src/i18n/languages.test.ts +43 -0
- package/src/i18n/languages.ts +86 -0
- package/src/i18n/runtime.test.ts +75 -0
- package/src/i18n/runtime.ts +53 -0
- package/src/i18n/types.ts +1452 -0
- package/src/i18n/zh-hant.ts +1849 -0
- package/src/i18n/zh.ts +1923 -0
- package/src/lib/ansi.test.ts +123 -0
- package/src/lib/ansi.ts +175 -0
- package/src/lib/chat-messages.test.ts +708 -0
- package/src/lib/chat-messages.ts +885 -0
- package/src/lib/chat-runtime.test.ts +18 -0
- package/src/lib/chat-runtime.ts +335 -0
- package/src/lib/clipboard.ts +28 -0
- package/src/lib/commit-changelog.test.ts +114 -0
- package/src/lib/commit-changelog.ts +177 -0
- package/src/lib/completion-sound.ts +519 -0
- package/src/lib/desktop-fs.test.ts +116 -0
- package/src/lib/desktop-fs.ts +113 -0
- package/src/lib/desktop-slash-commands.test.ts +126 -0
- package/src/lib/desktop-slash-commands.ts +286 -0
- package/src/lib/embedded-images.test.ts +35 -0
- package/src/lib/embedded-images.ts +60 -0
- package/src/lib/external-link.test.tsx +168 -0
- package/src/lib/external-link.tsx +303 -0
- package/src/lib/gateway-events.test.ts +27 -0
- package/src/lib/gateway-events.ts +49 -0
- package/src/lib/gateway-ws-url.test.ts +78 -0
- package/src/lib/gateway-ws-url.ts +91 -0
- package/src/lib/generated-images.test.ts +97 -0
- package/src/lib/generated-images.ts +116 -0
- package/src/lib/haptics.ts +129 -0
- package/src/lib/icons.ts +203 -0
- package/src/lib/incremental-external-store-runtime.ts +188 -0
- package/src/lib/katex-memo.ts +260 -0
- package/src/lib/keybinds/actions.ts +125 -0
- package/src/lib/keybinds/combo.test.ts +86 -0
- package/src/lib/keybinds/combo.ts +169 -0
- package/src/lib/local-preview.ts +126 -0
- package/src/lib/markdown-code.test.ts +23 -0
- package/src/lib/markdown-code.ts +195 -0
- package/src/lib/markdown-preprocess.ts +386 -0
- package/src/lib/media.remote.test.ts +58 -0
- package/src/lib/media.ts +111 -0
- package/src/lib/model-status-label.test.ts +31 -0
- package/src/lib/model-status-label.ts +103 -0
- package/src/lib/mutable-ref.ts +6 -0
- package/src/lib/preview-targets.test.ts +27 -0
- package/src/lib/preview-targets.ts +63 -0
- package/src/lib/profile-color.ts +58 -0
- package/src/lib/provider-setup-errors.test.ts +26 -0
- package/src/lib/provider-setup-errors.ts +12 -0
- package/src/lib/query-client.ts +13 -0
- package/src/lib/remend-tail.test.ts +105 -0
- package/src/lib/remend-tail.ts +108 -0
- package/src/lib/runtime-readiness.test.ts +65 -0
- package/src/lib/runtime-readiness.ts +147 -0
- package/src/lib/session-export.ts +57 -0
- package/src/lib/session-search.test.ts +58 -0
- package/src/lib/session-search.ts +19 -0
- package/src/lib/session-source.ts +62 -0
- package/src/lib/speech-text.ts +35 -0
- package/src/lib/statusbar.ts +91 -0
- package/src/lib/storage.test.ts +25 -0
- package/src/lib/storage.ts +107 -0
- package/src/lib/todos.test.ts +35 -0
- package/src/lib/todos.ts +51 -0
- package/src/lib/tool-result-summary.test.ts +106 -0
- package/src/lib/tool-result-summary.ts +467 -0
- package/src/lib/update-copy.test.ts +38 -0
- package/src/lib/update-copy.ts +44 -0
- package/src/lib/use-enter-animation.ts +100 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/voice-playback.ts +128 -0
- package/src/lib/yolo-session.ts +26 -0
- package/src/main.tsx +43 -0
- package/src/nastech.test.ts +49 -0
- package/src/nastech.ts +718 -0
- package/src/store/activity.ts +100 -0
- package/src/store/boot.ts +91 -0
- package/src/store/clarify.test.ts +81 -0
- package/src/store/clarify.ts +69 -0
- package/src/store/command-palette.ts +20 -0
- package/src/store/compaction.test.ts +53 -0
- package/src/store/compaction.ts +38 -0
- package/src/store/completion-sound.ts +32 -0
- package/src/store/composer-input-history.test.ts +147 -0
- package/src/store/composer-input-history.ts +158 -0
- package/src/store/composer-queue.test.ts +148 -0
- package/src/store/composer-queue.ts +239 -0
- package/src/store/composer-status.test.ts +99 -0
- package/src/store/composer-status.ts +277 -0
- package/src/store/composer.test.ts +106 -0
- package/src/store/composer.ts +184 -0
- package/src/store/cron.ts +19 -0
- package/src/store/gateway.ts +290 -0
- package/src/store/haptics.ts +17 -0
- package/src/store/keybinds.ts +139 -0
- package/src/store/layout.ts +176 -0
- package/src/store/model-presets.test.ts +51 -0
- package/src/store/model-presets.ts +86 -0
- package/src/store/model-visibility.test.ts +37 -0
- package/src/store/model-visibility.ts +108 -0
- package/src/store/native-notifications.test.ts +192 -0
- package/src/store/native-notifications.ts +203 -0
- package/src/store/notifications.ts +165 -0
- package/src/store/onboarding.test.ts +372 -0
- package/src/store/onboarding.ts +866 -0
- package/src/store/panes.test.ts +146 -0
- package/src/store/panes.ts +145 -0
- package/src/store/preview.test.ts +135 -0
- package/src/store/preview.ts +466 -0
- package/src/store/profile.test.ts +89 -0
- package/src/store/profile.ts +365 -0
- package/src/store/prompts.test.ts +121 -0
- package/src/store/prompts.ts +115 -0
- package/src/store/session-switcher.test.ts +115 -0
- package/src/store/session-switcher.ts +128 -0
- package/src/store/session-sync.ts +25 -0
- package/src/store/session.test.ts +131 -0
- package/src/store/session.ts +255 -0
- package/src/store/subagents.test.ts +111 -0
- package/src/store/subagents.ts +260 -0
- package/src/store/thread-scroll.ts +46 -0
- package/src/store/todos.test.ts +47 -0
- package/src/store/todos.ts +64 -0
- package/src/store/tool-diffs.ts +23 -0
- package/src/store/tool-dismiss.ts +45 -0
- package/src/store/tool-view.ts +91 -0
- package/src/store/translucency.ts +38 -0
- package/src/store/updates.test.ts +77 -0
- package/src/store/updates.ts +315 -0
- package/src/store/voice-playback.ts +24 -0
- package/src/store/windows.test.ts +143 -0
- package/src/store/windows.ts +77 -0
- package/src/styles.css +1235 -0
- package/src/themes/color.ts +142 -0
- package/src/themes/context.tsx +339 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/install.test.ts +119 -0
- package/src/themes/install.ts +95 -0
- package/src/themes/presets.test.ts +33 -0
- package/src/themes/presets.ts +293 -0
- package/src/themes/profile-theme.test.ts +41 -0
- package/src/themes/types.ts +66 -0
- package/src/themes/use-skin-command.ts +60 -0
- package/src/themes/user-themes.test.ts +63 -0
- package/src/themes/user-themes.ts +122 -0
- package/src/themes/vscode.test.ts +171 -0
- package/src/themes/vscode.ts +343 -0
- package/src/types/nastech.ts +646 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +56 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { atom } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
import { persistString, storedString } from '@/lib/storage'
|
|
4
|
+
|
|
5
|
+
import { notifyError } from './notifications'
|
|
6
|
+
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'nastech.desktop.model-presets'
|
|
9
|
+
|
|
10
|
+
/** Per-model reasoning/fast preset, remembered globally across sessions and
|
|
11
|
+
* re-applied to the session whenever that model is selected. Unset dimensions
|
|
12
|
+
* fall back to the NasTech default (medium effort, no fast). */
|
|
13
|
+
export interface ModelPreset {
|
|
14
|
+
effort?: string
|
|
15
|
+
fast?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
19
|
+
|
|
20
|
+
/** Stable `provider::model` key (matches the visibility-store format). */
|
|
21
|
+
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
|
|
22
|
+
|
|
23
|
+
function load(): Record<string, ModelPreset> {
|
|
24
|
+
const raw = storedString(STORAGE_KEY)
|
|
25
|
+
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw)
|
|
32
|
+
|
|
33
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
|
|
34
|
+
} catch {
|
|
35
|
+
return {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
|
|
40
|
+
|
|
41
|
+
export function getModelPreset(provider: string, model: string): ModelPreset {
|
|
42
|
+
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Merge a partial preset for one model and persist. */
|
|
46
|
+
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
|
|
47
|
+
const key = modelPresetKey(provider, model)
|
|
48
|
+
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
|
|
49
|
+
|
|
50
|
+
$modelPresets.set(next)
|
|
51
|
+
persistString(STORAGE_KEY, JSON.stringify(next))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Push a model's preset onto the active session (optimistic + gateway).
|
|
55
|
+
* `undefined` skips that dimension; values are capability-gated upstream.
|
|
56
|
+
* No-ops without a session — the gateway's `config.set` reasoning/fast fall
|
|
57
|
+
* back to persistent (global/profile) config when none matches, so selecting
|
|
58
|
+
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
|
|
59
|
+
export async function applyModelPreset(
|
|
60
|
+
{ effort, fast }: ModelPreset,
|
|
61
|
+
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (!ctx.sessionId) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (effort !== undefined) {
|
|
68
|
+
setCurrentReasoningEffort(effort)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (fast !== undefined) {
|
|
72
|
+
setCurrentFastMode(fast)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (effort !== undefined) {
|
|
77
|
+
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (fast !== undefined) {
|
|
81
|
+
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
notifyError(err, ctx.failMessage)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ModelOptionProvider } from '@/types/nastech'
|
|
4
|
+
|
|
5
|
+
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
|
|
6
|
+
|
|
7
|
+
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
|
|
8
|
+
models,
|
|
9
|
+
name: slug,
|
|
10
|
+
slug
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('model visibility', () => {
|
|
14
|
+
it('keeps newly configured providers visible when stored choices are stale', () => {
|
|
15
|
+
const stored = new Set([modelVisibilityKey('copilot', 'claude-sonnet-4.6')])
|
|
16
|
+
|
|
17
|
+
const visible = effectiveVisibleKeys(stored, [
|
|
18
|
+
provider('copilot', ['claude-sonnet-4.6']),
|
|
19
|
+
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
expect(visible.has(modelVisibilityKey('copilot', 'claude-sonnet-4.6'))).toBe(true)
|
|
23
|
+
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
|
24
|
+
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does not re-add models from a provider that already has stored choices', () => {
|
|
28
|
+
const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
|
|
29
|
+
|
|
30
|
+
const visible = effectiveVisibleKeys(stored, [
|
|
31
|
+
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
|
35
|
+
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { atom } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
import { persistString, storedString } from '@/lib/storage'
|
|
4
|
+
import type { ModelOptionProvider } from '@/types/nastech'
|
|
5
|
+
|
|
6
|
+
const STORAGE_KEY = 'NASTECH.desktop.visible-models'
|
|
7
|
+
|
|
8
|
+
/** Models shown per provider in the status-bar dropdown before the user has
|
|
9
|
+
* customized the list. Backend `models` are already relevance-ordered. */
|
|
10
|
+
export const DEFAULT_VISIBLE_PER_PROVIDER = 50
|
|
11
|
+
|
|
12
|
+
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
|
|
13
|
+
* that contain a single colon, e.g. `model:tag`). */
|
|
14
|
+
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
|
|
15
|
+
|
|
16
|
+
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
|
|
17
|
+
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
|
|
18
|
+
export interface ModelFamily {
|
|
19
|
+
fastId: string | null
|
|
20
|
+
id: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Collapse a provider's model list so a base model and its `…-fast` variant
|
|
24
|
+
* become a single family (one row, one toggle). Order is preserved by the
|
|
25
|
+
* base model's position. A `…-fast` model with no base stands on its own. */
|
|
26
|
+
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
|
|
27
|
+
const present = new Set(models)
|
|
28
|
+
const families: ModelFamily[] = []
|
|
29
|
+
const consumed = new Set<string>()
|
|
30
|
+
|
|
31
|
+
for (const model of models) {
|
|
32
|
+
if (consumed.has(model)) {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
|
|
37
|
+
// Represented by its base entry — the base attaches it as `fastId`.
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fastId = `${model}-fast`
|
|
42
|
+
const hasFast = present.has(fastId)
|
|
43
|
+
families.push({ fastId: hasFast ? fastId : null, id: model })
|
|
44
|
+
consumed.add(model)
|
|
45
|
+
|
|
46
|
+
if (hasFast) {
|
|
47
|
+
consumed.add(fastId)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return families
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadVisible(): Set<string> | null {
|
|
55
|
+
const raw = storedString(STORAGE_KEY)
|
|
56
|
+
|
|
57
|
+
if (!raw) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(raw)
|
|
63
|
+
|
|
64
|
+
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Explicit set of visible `provider::model` keys, or null when the user
|
|
71
|
+
* hasn't customized — in which case the curated default applies. */
|
|
72
|
+
export const $visibleModels = atom<Set<string> | null>(loadVisible())
|
|
73
|
+
|
|
74
|
+
export const $modelVisibilityOpen = atom(false)
|
|
75
|
+
|
|
76
|
+
export function setVisibleModels(keys: Set<string>): void {
|
|
77
|
+
$visibleModels.set(new Set(keys))
|
|
78
|
+
persistString(STORAGE_KEY, JSON.stringify([...keys]))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setModelVisibilityOpen(open: boolean): void {
|
|
82
|
+
$modelVisibilityOpen.set(open)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The default-visible key set: the curated top-N per provider. Used both as
|
|
86
|
+
* the dropdown fallback and to seed the Edit Models dialog. */
|
|
87
|
+
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
|
|
88
|
+
const keys = new Set<string>()
|
|
89
|
+
|
|
90
|
+
for (const provider of providers) {
|
|
91
|
+
const families = collapseModelFamilies(provider.models ?? [])
|
|
92
|
+
|
|
93
|
+
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
|
94
|
+
keys.add(modelVisibilityKey(provider.slug, family.id))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return keys
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve which keys are currently visible: the user's explicit set when
|
|
102
|
+
* configured, otherwise the curated default for the given providers. */
|
|
103
|
+
export function effectiveVisibleKeys(
|
|
104
|
+
stored: Set<string> | null,
|
|
105
|
+
providers: readonly ModelOptionProvider[]
|
|
106
|
+
): Set<string> {
|
|
107
|
+
return stored ?? defaultVisibleKeys(providers)
|
|
108
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { $gateway } from './gateway'
|
|
4
|
+
import {
|
|
5
|
+
dispatchNativeNotification,
|
|
6
|
+
NATIVE_NOTIFICATION_KINDS,
|
|
7
|
+
respondToApprovalAction,
|
|
8
|
+
sendTestNativeNotification,
|
|
9
|
+
setNativeNotifyEnabled,
|
|
10
|
+
setNativeNotifyKind
|
|
11
|
+
} from './native-notifications'
|
|
12
|
+
import { $approvalRequest, setApprovalRequest } from './prompts'
|
|
13
|
+
import { $activeSessionId, setActiveSessionId } from './session'
|
|
14
|
+
|
|
15
|
+
const desktopWindow = window as unknown as { nastechDesktop?: Window['nastechDesktop'] }
|
|
16
|
+
const initialNasTechDesktop = desktopWindow.nastechDesktop
|
|
17
|
+
|
|
18
|
+
const notify = vi.fn().mockResolvedValue(true)
|
|
19
|
+
|
|
20
|
+
function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
|
|
21
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
|
22
|
+
Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let counter = 0
|
|
26
|
+
|
|
27
|
+
// Unique session id per call dodges the per-(kind,session) throttle so each
|
|
28
|
+
// assertion starts clean.
|
|
29
|
+
function freshSession(): string {
|
|
30
|
+
counter += 1
|
|
31
|
+
|
|
32
|
+
return `session-${counter}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
notify.mockClear()
|
|
37
|
+
desktopWindow.nastechDesktop = { notify } as unknown as Window['nastechDesktop']
|
|
38
|
+
setNativeNotifyEnabled(true)
|
|
39
|
+
|
|
40
|
+
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
|
41
|
+
setNativeNotifyKind(kind, true)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setActiveSessionId(null)
|
|
45
|
+
setWindowState({ focused: false, hidden: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
if (initialNasTechDesktop) {
|
|
50
|
+
desktopWindow.nastechDesktop = initialNasTechDesktop
|
|
51
|
+
} else {
|
|
52
|
+
delete desktopWindow.nastechDesktop
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('dispatchNativeNotification focus gating', () => {
|
|
57
|
+
it('fires a completion notification for the active session when the window is hidden', () => {
|
|
58
|
+
const sessionId = freshSession()
|
|
59
|
+
setActiveSessionId(sessionId)
|
|
60
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
|
61
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
|
|
65
|
+
const sessionId = freshSession()
|
|
66
|
+
setActiveSessionId(sessionId)
|
|
67
|
+
setWindowState({ focused: false, hidden: false })
|
|
68
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
|
69
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('suppresses a completion notification when the window is focused', () => {
|
|
73
|
+
const sessionId = freshSession()
|
|
74
|
+
setActiveSessionId(sessionId)
|
|
75
|
+
setWindowState({ focused: true, hidden: false })
|
|
76
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
|
77
|
+
expect(notify).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
|
|
81
|
+
setActiveSessionId('on-screen')
|
|
82
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
|
|
83
|
+
expect(notify).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('fires an attention notification for an off-screen session even when focused', () => {
|
|
87
|
+
setWindowState({ focused: true, hidden: false })
|
|
88
|
+
setActiveSessionId('on-screen')
|
|
89
|
+
dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
|
|
90
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('suppresses an attention notification for the active session when focused', () => {
|
|
94
|
+
setWindowState({ focused: true, hidden: false })
|
|
95
|
+
setActiveSessionId('on-screen')
|
|
96
|
+
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
|
|
97
|
+
expect(notify).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('dispatchNativeNotification preferences', () => {
|
|
102
|
+
it('suppresses everything when the master switch is off', () => {
|
|
103
|
+
setNativeNotifyEnabled(false)
|
|
104
|
+
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
|
|
105
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
|
|
106
|
+
expect(notify).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('suppresses only the disabled kind', () => {
|
|
110
|
+
const sessionId = freshSession()
|
|
111
|
+
setActiveSessionId(sessionId)
|
|
112
|
+
setNativeNotifyKind('turnDone', false)
|
|
113
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
|
114
|
+
expect(notify).not.toHaveBeenCalled()
|
|
115
|
+
|
|
116
|
+
dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
|
|
117
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('forwards kind and sessionId to the bridge', () => {
|
|
121
|
+
setActiveSessionId('abc')
|
|
122
|
+
dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
|
|
123
|
+
expect(notify).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('dispatchNativeNotification throttle', () => {
|
|
130
|
+
it('collapses duplicate kind+session within the throttle window', () => {
|
|
131
|
+
const sessionId = freshSession()
|
|
132
|
+
setActiveSessionId(sessionId)
|
|
133
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
|
134
|
+
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
|
|
135
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('sendTestNativeNotification', () => {
|
|
140
|
+
it('fires regardless of focus or active session', () => {
|
|
141
|
+
setWindowState({ focused: true, hidden: false })
|
|
142
|
+
setActiveSessionId('on-screen')
|
|
143
|
+
sendTestNativeNotification('NasTech', 'works')
|
|
144
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('$activeSessionId wiring', () => {
|
|
149
|
+
it('reflects the setter used for gating', () => {
|
|
150
|
+
setActiveSessionId('xyz')
|
|
151
|
+
expect($activeSessionId.get()).toBe('xyz')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('respondToApprovalAction', () => {
|
|
156
|
+
const request = vi.fn().mockResolvedValue({ resolved: true })
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
request.mockClear()
|
|
160
|
+
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
$gateway.set(null)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
|
|
168
|
+
setActiveSessionId('bg')
|
|
169
|
+
setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
|
|
170
|
+
|
|
171
|
+
await respondToApprovalAction('bg', 'approve')
|
|
172
|
+
|
|
173
|
+
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
|
|
174
|
+
expect($approvalRequest.get()).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('rejects via approval.respond {choice: "deny"}', async () => {
|
|
178
|
+
await respondToApprovalAction('bg', 'reject')
|
|
179
|
+
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('ignores unknown action ids', async () => {
|
|
183
|
+
await respondToApprovalAction('bg', 'snooze')
|
|
184
|
+
expect(request).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('no-ops without a gateway', async () => {
|
|
188
|
+
$gateway.set(null)
|
|
189
|
+
await respondToApprovalAction('bg', 'approve')
|
|
190
|
+
expect(request).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { atom } from 'nanostores'
|
|
2
|
+
|
|
3
|
+
import { persistString, storedString } from '@/lib/storage'
|
|
4
|
+
|
|
5
|
+
import { $gateway } from './gateway'
|
|
6
|
+
import { clearApprovalRequest } from './prompts'
|
|
7
|
+
import { $activeSessionId } from './session'
|
|
8
|
+
|
|
9
|
+
// Native OS notifications (Electron `Notification`), separate from the in-app
|
|
10
|
+
// toast feed in `notifications.ts`. Each kind toggles independently.
|
|
11
|
+
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
|
|
12
|
+
|
|
13
|
+
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
|
14
|
+
'approval',
|
|
15
|
+
'input',
|
|
16
|
+
'turnDone',
|
|
17
|
+
'turnError',
|
|
18
|
+
'backgroundDone'
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Blocking prompts — surface even while focused if they're for another session.
|
|
22
|
+
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
|
|
23
|
+
|
|
24
|
+
export interface NativeNotificationPrefs {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
kinds: Record<NativeNotificationKind, boolean>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STORAGE_KEY = 'nastech:native-notifications'
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPrefs(): NativeNotificationPrefs {
|
|
37
|
+
const raw = storedString(STORAGE_KEY)
|
|
38
|
+
|
|
39
|
+
if (!raw) {
|
|
40
|
+
return DEFAULT_PREFS
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
|
|
45
|
+
const kinds = { ...DEFAULT_PREFS.kinds }
|
|
46
|
+
|
|
47
|
+
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
|
48
|
+
const value = parsed.kinds?.[kind]
|
|
49
|
+
|
|
50
|
+
if (typeof value === 'boolean') {
|
|
51
|
+
kinds[kind] = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
|
|
57
|
+
kinds
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return DEFAULT_PREFS
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
|
|
65
|
+
|
|
66
|
+
function writePrefs(next: NativeNotificationPrefs) {
|
|
67
|
+
$nativeNotifyPrefs.set(next)
|
|
68
|
+
persistString(STORAGE_KEY, JSON.stringify(next))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setNativeNotifyEnabled(enabled: boolean) {
|
|
72
|
+
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
|
|
76
|
+
const prev = $nativeNotifyPrefs.get()
|
|
77
|
+
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// De-dupe replayed events for the same kind+session. Self-evicting: entries
|
|
81
|
+
// older than the window are pruned on every dispatch, so the map can't grow.
|
|
82
|
+
const THROTTLE_MS = 1000
|
|
83
|
+
const lastFiredAt = new Map<string, number>()
|
|
84
|
+
|
|
85
|
+
function throttled(key: string, now: number): boolean {
|
|
86
|
+
for (const [k, at] of lastFiredAt) {
|
|
87
|
+
if (now - at >= THROTTLE_MS) {
|
|
88
|
+
lastFiredAt.delete(k)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (lastFiredAt.has(key)) {
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lastFiredAt.set(key, now)
|
|
97
|
+
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// "Backgrounded" = the user isn't on NasTech. `document.hidden` only flips when
|
|
102
|
+
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
|
|
103
|
+
// check `document.hasFocus()`.
|
|
104
|
+
function isBackgrounded(): boolean {
|
|
105
|
+
if (typeof document === 'undefined') {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (document.hidden) {
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return typeof document.hasFocus === 'function' && !document.hasFocus()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
|
|
117
|
+
// Attention kinds break through for an off-screen session even while focused.
|
|
118
|
+
if (ATTENTION_KINDS.has(kind)) {
|
|
119
|
+
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Completion kinds: only the active session, only while away — so a busy
|
|
123
|
+
// gateway (messaging, kanban, cron) can't spam a toast per background session.
|
|
124
|
+
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface NativeNotificationAction {
|
|
128
|
+
id: string
|
|
129
|
+
text: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NativeNotificationInput {
|
|
133
|
+
kind: NativeNotificationKind
|
|
134
|
+
title: string
|
|
135
|
+
body?: string
|
|
136
|
+
sessionId?: null | string
|
|
137
|
+
silent?: boolean
|
|
138
|
+
actions?: NativeNotificationAction[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
|
142
|
+
const prefs = $nativeNotifyPrefs.get()
|
|
143
|
+
|
|
144
|
+
if (!prefs.enabled || !prefs.kinds[input.kind]) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!shouldFire(input.kind, input.sessionId)) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
void window.NASTECHDesktop?.notify({
|
|
157
|
+
actions: input.actions,
|
|
158
|
+
body: input.body,
|
|
159
|
+
kind: input.kind,
|
|
160
|
+
sessionId: input.sessionId ?? undefined,
|
|
161
|
+
silent: input.silent,
|
|
162
|
+
title: input.title
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Resolve a pending approval from a notification button, mirroring the in-app
|
|
167
|
+
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
|
|
168
|
+
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
|
|
169
|
+
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
|
|
170
|
+
|
|
171
|
+
if (!choice) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const gateway = $gateway.get()
|
|
176
|
+
|
|
177
|
+
if (!gateway) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
|
|
183
|
+
clearApprovalRequest(sessionId)
|
|
184
|
+
} catch {
|
|
185
|
+
// Leave the prompt parked so the user can still resolve it in-app.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
|
|
190
|
+
// the panel can flag a silent permission failure instead of looking dead.
|
|
191
|
+
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
|
|
192
|
+
const bridge = window.NASTECHDesktop
|
|
193
|
+
|
|
194
|
+
if (!bridge?.notify) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return await bridge.notify({ body, kind: 'turnDone', title })
|
|
200
|
+
} catch {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
}
|