diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index dea5e22c6a..77c9b6168b 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,4 +1,4 @@ -name: Build Desktop App +name: Desktop CI on: push: @@ -9,7 +9,8 @@ on: workflow_dispatch: jobs: - build-mac: + verify: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v') runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -25,50 +26,96 @@ jobs: version: 10 - name: Install OpenClaw deps - run: cd openclaw && pnpm install + run: cd openclaw && pnpm install --frozen-lockfile - name: Build OpenClaw run: cd openclaw && pnpm build && pnpm ui:build - name: Install Desktop deps - run: cd desktop && npm install + run: cd desktop && npm ci + + - name: Run Desktop checks + run: cd desktop && npm run check:ci + + - name: Build Desktop + run: cd desktop && npm run build:all + + release: + if: startsWith(github.ref, 'refs/tags/v') + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + electron_args: --mac zip + artifact_name: openspace-release-mac + - os: windows-latest + electron_args: --win nsis + artifact_name: openspace-release-win + runs-on: ${{ matrix.os }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install OpenClaw deps + run: cd openclaw && pnpm install --frozen-lockfile + + - name: Build OpenClaw + run: cd openclaw && pnpm build && pnpm ui:build + + - name: Install Desktop deps + run: cd desktop && npm ci + + - name: Run Desktop checks + run: cd desktop && npm run check:ci - name: Prepare OpenClaw bundle - run: cd desktop && npm run prepare:openclaw + run: cd desktop && npm run prepare:openclaw:ci - name: Prepare runtimes - run: cd desktop && npm run prepare:node + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cd desktop && npm run prepare:runtimes - name: Build Desktop run: cd desktop && npm run build:all - - name: Package + - name: Package and publish release assets env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_IDENTITY_AUTO_DISCOVERY: false - run: cd desktop && npx electron-builder --publish never + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + CSC_NAME: ${{ secrets.CSC_NAME }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + NOTARYTOOL_PROFILE: ${{ secrets.NOTARYTOOL_PROFILE }} + NOTARYTOOL_KEY: ${{ secrets.NOTARYTOOL_KEY }} + NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }} + NOTARYTOOL_ISSUER: ${{ secrets.NOTARYTOOL_ISSUER }} + NOTARIZE: ${{ vars.OPENSPACE_NOTARIZE }} + run: cd desktop && npx electron-builder ${{ matrix.electron_args }} --publish always - - name: Upload artifacts + - name: Upload published artifacts uses: actions/upload-artifact@v4 with: - name: openspace-mac-${{ github.sha }} - path: desktop/release/*.{dmg,zip} + name: ${{ matrix.artifact_name }}-${{ github.sha }} + path: | + desktop/release/*.zip + desktop/release/*.dmg + desktop/release/*.exe + desktop/release/*.blockmap + desktop/release/*.yml if-no-files-found: warn - - release: - needs: build-mac - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - name: openspace-mac-${{ github.sha }} - path: artifacts - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - draft: true - files: artifacts/* - generate_release_notes: true diff --git a/.github/workflows/sync-openclaw.yml b/.github/workflows/sync-openclaw.yml index a07fe7cf72..03ea96602c 100644 --- a/.github/workflows/sync-openclaw.yml +++ b/.github/workflows/sync-openclaw.yml @@ -8,6 +8,8 @@ on: jobs: sync: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 with: diff --git a/desktop/README.md b/desktop/README.md index 543a19fbf3..0bc90b18e3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -1,16 +1,17 @@ -# Atomic Bot Desktop +# OpenSpace Desktop -Cross-platform Electron desktop app for [Atomic Bot](https://atomicbot.ai) — an AI assistant that makes things for you. +Cross-platform Electron desktop app for OpenSpace. **Platforms:** macOS (arm64 / x64) · Windows (x64) ## Quick Start ```bash -# Install dependencies (from repo root) -pnpm install +# From repo root, install monorepo + desktop deps +cd openclaw && pnpm install +cd ../desktop && npm install -# Prepare bundled runtimes +# Prepare bundled runtimes used by the packaged app npm run prepare:all # Build & launch in dev mode @@ -22,7 +23,7 @@ npm run dev The app follows a standard Electron multi-process model with a clear separation of concerns: ``` -apps/electron-desktop/ +desktop/ ├── src/ # Main process + preload │ ├── main.ts # Electron entry point │ ├── preload.ts # Context bridge (window.openclawDesktop) @@ -128,13 +129,52 @@ Channel names live in `ipc-channels.ts`; the full API surface is declared in `de | `npm run format` | Check formatting (Prettier) | | `npm run format:fix` | Fix formatting (Prettier) | +## Release & Auto-update + +The packaged app uses `electron-updater` with the GitHub provider configured in `package.json`. Release builds are published to the GitHub Releases page of `guilhermexp/openspace`. + +Manual download assets: + +- macOS: `.dmg` +- Windows: `.exe` + +Auto-update assets: + +- macOS: `.zip`, `.blockmap`, `latest-mac.yml` +- Windows: `.exe`, `.blockmap`, `latest.yml` + +If you mirror installers on an external site, keep GitHub Releases as the canonical update feed unless you also migrate the app to a generic update provider. + +Guia de secrets e variables: + +- [release-secrets-checklist.md](/Users/guilhermevarela/Documents/Projetos/openspace/desktop/docs/release-secrets-checklist.md) + +Tag-driven release flow: + +1. Run `npm run release patch|minor|major` inside `desktop/` +2. Push the branch and tag +3. GitHub Actions builds macOS + Windows artifacts and publishes them to the draft release +4. Publish the draft release after both platform jobs complete + +Optional signing/notarization secrets for release CI: + +- `CSC_LINK` +- `CSC_KEY_PASSWORD` +- `CSC_NAME` +- `APPLE_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` +- `APPLE_TEAM_ID` +- `NOTARYTOOL_PROFILE` or `NOTARYTOOL_KEY` + `NOTARYTOOL_KEY_ID` + `NOTARYTOOL_ISSUER` +- repo variable `OPENSPACE_NOTARIZE=1` to enable notarization steps + ## Environment Variables -| Variable | Context | Description | -| ----------------------------- | -------- | ------------------------------------------------------- | -| `VITE_BACKEND_URL` | Renderer | Override API backend URL (set in `renderer/.env.local`) | -| `OPENCLAW_DESKTOP_NODE_BIN` | Main | Custom Node binary path for development | -| `CSC_IDENTITY_AUTO_DISCOVERY` | Build | Set to `false` to skip code signing | +| Variable | Context | Description | +| ---------------------------------------------------------------- | -------- | ------------------------------------------------------- | +| `VITE_BACKEND_URL` | Renderer | Override API backend URL (set in `renderer/.env.local`) | +| `OPENCLAW_DESKTOP_NODE_BIN` | Main | Custom Node binary path for development | +| `CSC_IDENTITY_AUTO_DISCOVERY` | Build | Set to `false` to skip code signing | +| `OPENCLAW_GOG_OAUTH_CLIENT_SECRET_PATH` / `..._B64` / `..._JSON` | Build | Stage the gog OAuth client secret for packaged builds | ## Adding New Features diff --git a/desktop/docs/gateway-message-metadata.md b/desktop/docs/gateway-message-metadata.md index 10c7ad32c5..e877bba31e 100644 --- a/desktop/docs/gateway-message-metadata.md +++ b/desktop/docs/gateway-message-metadata.md @@ -139,7 +139,7 @@ CSS em: `ui/src/styles/chat/grouped.css` (classes `.msg-meta__*`) ### 4.1 Parser de historico -Arquivo: `apps/electron-desktop/renderer/src/store/slices/chat/chat-utils.ts` +Arquivo: `desktop/renderer/src/store/slices/chat/chat-utils.ts` O `parseHistoryMessages()` extrai `usage` e `model` de cada mensagem raw: @@ -163,7 +163,7 @@ const messageModel = ### 4.2 Tipos -Arquivo: `apps/electron-desktop/renderer/src/store/slices/chat/chat-types.ts` +Arquivo: `desktop/renderer/src/store/slices/chat/chat-types.ts` ```typescript type UiMessageUsage = { @@ -182,12 +182,12 @@ type UiMessage = { ### 4.3 Componente de renderizacao -Arquivo: `apps/electron-desktop/renderer/src/ui/chat/components/MessageMeta.tsx` +Arquivo: `desktop/renderer/src/ui/chat/components/MessageMeta.tsx` Renderiza inline abaixo de cada mensagem do assistente: ``` -Atomic Bot 13:40 ↑4k ↓371 R26k W212 claude-opus-4-6 +OpenSpace 13:40 ↑4k ↓371 R26k W212 claude-opus-4-6 ``` Props recebidas diretamente da mensagem: @@ -200,7 +200,7 @@ Fallback: se `model` nao vier na mensagem, le do config (`agents.defaults.model. ### 4.4 Integracao no ChatMessageList -Arquivo: `apps/electron-desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` +Arquivo: `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` ```tsx { @@ -226,19 +226,19 @@ Arquivo: `apps/electron-desktop/renderer/src/ui/chat/components/ChatMessageList. ↓ [Redux Store] → UiMessage com usage/model ↓ -[MessageMeta] → renderiza inline: "Atomic Bot 13:40 ↑4k ↓371 R26k claude-opus-4-6" +[MessageMeta] → renderiza inline: "OpenSpace 13:40 ↑4k ↓371 R26k claude-opus-4-6" ``` --- ## 6. Referencia de arquivos -| Arquivo | Responsabilidade | -| ------------------------------------------------------------------------------ | ---------------------------------------- | -| `ui/src/ui/chat/grouped-render.ts` | Control UI — extrai e renderiza metadata | -| `ui/src/styles/chat/grouped.css` | Control UI — estilos `.msg-meta__*` | -| `apps/electron-desktop/renderer/src/store/slices/chat/chat-types.ts` | Tipos `UiMessageUsage`, `UiMessage` | -| `apps/electron-desktop/renderer/src/store/slices/chat/chat-utils.ts` | Parser `parseHistoryMessages()` | -| `apps/electron-desktop/renderer/src/ui/chat/components/MessageMeta.tsx` | Componente de metadata inline | -| `apps/electron-desktop/renderer/src/ui/chat/components/MessageMeta.module.css` | Estilos do MessageMeta | -| `apps/electron-desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` | Integracao do MessageMeta nas mensagens | +| Arquivo | Responsabilidade | +| ---------------------------------------------------------------- | ---------------------------------------- | +| `ui/src/ui/chat/grouped-render.ts` | Control UI — extrai e renderiza metadata | +| `ui/src/styles/chat/grouped.css` | Control UI — estilos `.msg-meta__*` | +| `desktop/renderer/src/store/slices/chat/chat-types.ts` | Tipos `UiMessageUsage`, `UiMessage` | +| `desktop/renderer/src/store/slices/chat/chat-utils.ts` | Parser `parseHistoryMessages()` | +| `desktop/renderer/src/ui/chat/components/MessageMeta.tsx` | Componente de metadata inline | +| `desktop/renderer/src/ui/chat/components/MessageMeta.module.css` | Estilos do MessageMeta | +| `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` | Integracao do MessageMeta nas mensagens | diff --git a/desktop/docs/plans/2026-03-31-desktop-openclaw-current-state.md b/desktop/docs/plans/2026-03-31-desktop-openclaw-current-state.md new file mode 100644 index 0000000000..c143514c98 --- /dev/null +++ b/desktop/docs/plans/2026-03-31-desktop-openclaw-current-state.md @@ -0,0 +1,506 @@ +# OpenSpace Desktop + OpenClaw Current State + +> Data: 2026-03-31 +> Projeto: OpenSpace Desktop +> Status: documentado conforme estado atual do ambiente e do codigo + +## Objetivo + +Registrar o estado atual do desktop e da integracao com o `openclaw` depois dos ajustes feitos nesta sessao, incluindo: + +- gateway local na porta `1515` +- conflito entre desktop dev e `LaunchAgent` global +- desativacao de PostHog +- comportamento atual de providers/modelos +- persistencia e saneamento do setup token da Anthropic + +## Resumo Executivo + +O estado correto agora e: + +- o `desktop` em dev prefere a porta `1515` +- o `openclaw` global local tambem foi alinhado para `1515` +- quando o `LaunchAgent` global estiver ocupando `1515`, o desktop dev descarrega esse servico antes de subir seu gateway embutido +- PostHog foi desligado de verdade no renderer e no main process +- o fluxo de providers/modelos foi alinhado com o AtomicBot no que estava quebrado +- o `models.list` no `openclaw` voltou a devolver catalogo completo, evitando provider “configurado” com lista vazia de modelos +- o setup token da Anthropic agora e sanitizado antes de persistir, removendo whitespace que quebrava o bearer token +- o fluxo de voz no desktop agora suporta: + - transcricao local e remota sem `audio.transcribe` + - mensagens de voz por sessao + - respostas em audio usando a mesma OpenAI key salva no Electron + - renderizacao inline de audio e imagem no chat + - `voice mode` com texto oculto por padrao e logs recolhidos automaticamente + +## Gateway + +### Porta correta + +A porta local correta do ambiente atual e `1515`. + +### Estado do desktop + +O desktop usa `1515` como porta padrao: + +- `desktop/src/main/constants.ts` + +O bootstrap do desktop agora executa uma preflight em dev/macOS: + +- `desktop/src/main/bootstrap/app-bootstrap.ts` +- `desktop/src/main/bootstrap/dev-global-gateway.ts` + +Comportamento: + +- se o app estiver empacotado, nao faz nada +- se nao for macOS, nao faz nada +- se `1515` estiver livre, nao faz nada +- se `1515` estiver ocupada pelo `LaunchAgent` global `ai.openclaw.gateway`, o desktop roda `launchctl bootout gui//ai.openclaw.gateway` e depois sobe o gateway embutido + +### Estado do servico global + +O servico global do `openclaw` local foi alinhado para `1515`. + +Arquivos locais relevantes: + +- `~/.openclaw/openclaw.json` +- `~/Library/LaunchAgents/ai.openclaw.gateway.plist` + +Estado esperado: + +- `gateway.port = 1515` na config local +- `OPENCLAW_GATEWAY_PORT=1515` no plist +- `openclaw gateway status` deve mostrar `Probe target: ws://127.0.0.1:1515` + +### Regra operacional + +Existem dois jeitos de rodar gateway localmente: + +1. servico global instalado via `openclaw gateway ...` +2. gateway embutido do desktop em dev + +Se os dois disputarem a mesma porta, o comportamento antigo era o desktop cair para uma porta aleatoria. Agora o desktop tenta resolver isso descarregando o servico global em dev/macOS. + +## Providers E Modelos + +### Problema original + +O provider aparecia como configurado, mas a UI ficava sem modelos para selecionar. + +### Causa raiz + +A divergencia real estava no `openclaw`, nao so no desktop. + +Arquivo: + +- `openclaw/src/gateway/server-methods/models.ts` + +O fork local estava filtrando o catalogo de modelos; o AtomicBot devolvia o catalogo completo. Isso quebrava a selecao de modelos para provider recem-configurado. + +### Estado atual + +`models.list` foi alinhado de volta ao comportamento do AtomicBot: catalogo completo. + +Teste relacionado: + +- `openclaw/src/gateway/server.models-voicewake-misc.test.ts` + +### Desktop + +No desktop tambem ficaram alinhados os pontos de troca de modo/auth que estavam puxando comportamento do dashboard legacy: + +- `desktop/renderer/src/store/slices/auth/authSlice.ts` +- `desktop/renderer/src/store/slices/session-model-reset.ts` +- `desktop/renderer/src/ui/settings/account-models/AccountModelsTab.tsx` +- `desktop/renderer/src/ui/settings/providers/useModelProvidersState.ts` + +Estado esperado: + +- provider configurado deve permitir selecionar modelo na propria tela +- troca entre subscription e own API key nao deve ficar presa em estado legado + +## PostHog + +PostHog foi desligado de verdade no desktop. + +Arquivos: + +- `desktop/renderer/src/analytics/posthog-client.ts` +- `desktop/src/main/analytics/posthog-main.ts` + +Estado esperado: + +- nao chamar `posthog.init` +- nao enviar eventos +- sumir o warning `PostHog was initialized without a token` como efeito colateral da desativacao real + +## Anthropic Setup Token + +### Problema original + +A UI aceitava o token, marcava o provider como configurado, mas a autenticacao falhava com: + +- `HTTP 401 authentication_error: Invalid bearer token` + +### Causa raiz encontrada + +O token estava sendo persistido, mas com whitespace interno. + +Arquivo local de runtime: + +- `~/Library/Application Support/openspace-desktop/openclaw/agents/main/agent/auth-profiles.json` + +Profile relevante: + +- `anthropic:default` + +Tipo: + +- `token` + +### Correcoes aplicadas + +Normalizacao de token no main process: + +- `desktop/src/main/keys/authProfilesStore.ts` +- `desktop/src/main/keys/apiKeys.ts` + +Comportamento atual: + +- ao ler `auth-profiles.json`, token `type: "token"` e sanitizado com remocao de whitespace +- ao persistir novo token, whitespace interno tambem e removido +- `writeAuthProfilesStoreAtomic` tambem sanitize o token, protegendo contra regravacao ruim vinda de restore/local state + +Teste relacionado: + +- `desktop/src/main/keys/authProfilesStore.test.ts` + +### Estado local corrigido + +O token Anthropic salvo localmente ja foi sanitizado no arquivo de runtime acima. + +Estado esperado agora: + +- `anthropic:default` continua presente +- token sem espacos ou quebras de linha +- login por Claude subscription deixa de falhar por erro de formacao local do bearer token + +## Voice Mode E Midia Inline + +### Objetivo do fluxo + +O desktop agora suporta um fluxo de conversa por voz por sessao: + +- o mic original do composer continua sendo transcricao para texto +- existe um segundo botao no chat input para mensagem de voz +- ao usar mensagem de voz, a fala e transcrita e enviada como mensagem +- a sessao pode entrar em `voice mode`, fazendo a resposta voltar em audio +- quando `voice mode` esta ativo, o audio e tratado como saida principal do turno + +### Captura e transcricao de voz + +Estado atual do desktop: + +- `desktop/renderer/src/ui/chat/hooks/useVoiceInput.ts` +- `desktop/renderer/src/ui/chat/hooks/useWavRecorder.ts` +- `desktop/src/main/whisper/ipc.ts` + +Comportamento atual: + +- tanto `local` quanto `openai` gravam em WAV via `useWavRecorder` +- o modo `openai` nao depende mais do `MediaRecorder` do Chromium para enviar `webm/ogg` +- a transcricao OpenAI e enviada como `audio/wav` com `recording.wav` +- isso remove o erro intermitente `HTTP 400: Audio file might be corrupted or unsupported` + +### TTS com key do Electron + +O provider `openai` de TTS reaproveita a API key salva no Electron, sem precisar duplicar segredo em `openclaw.json`. + +Arquivos relevantes: + +- `desktop/src/main/keys/openai-api-key.ts` +- `desktop/src/main/gateway/spawn.ts` +- `openclaw/extensions/openai/speech-provider.ts` + +### Renderizacao inline no chat + +O chat do desktop agora renderiza inline: + +- audio de `tts` +- imagens geradas por tool +- artefatos locais resolvidos via bridge do Electron + +Arquivos relevantes: + +- `desktop/renderer/src/ui/chat/components/ToolCallCard.tsx` +- `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` +- `desktop/renderer/src/ui/chat/components/inline-media.tsx` +- `desktop/src/main/ipc/file-reader.ts` +- `desktop/src/preload.ts` + +### Ordenacao visual do turno + +Estado esperado do turno do assistente em `voice mode`: + +- logs normais primeiro, se existirem +- texto do assistente apenas quando estrutural +- imagens/tool outputs visuais no meio +- audio de `tts` por ultimo, como fechamento do turno + +Isso foi alinhado em: + +- `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` + +### Texto oculto por padrao no modo voz + +Quando o `voice mode` esta ativo: + +- texto normal do assistente fica oculto por padrao +- texto continua aparecendo automaticamente se contiver: + - link/URL + - comando/codigo + - caminho de arquivo + - lista/passos + - JSON/tabela/bloco estrutural + +Arquivos relevantes: + +- `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` +- `desktop/renderer/src/ui/chat/components/ToolCallCard.tsx` + +### Logs recolhidos automaticamente em voice mode + +Para evitar que o usuario tenha de rolar ate cima para achar o audio: + +- `ActionLog` comum entra recolhido por padrao em `voice mode` +- `ActionLog` ao vivo tambem entra recolhido +- o card de `tts` NAO e recolhido, para o play continuar imediatamente visivel +- logs continuam expandiveis manualmente + +Arquivos relevantes: + +- `desktop/renderer/src/ui/chat/components/ActionLog.tsx` +- `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` + +### Botao de mensagem de voz + +Estado esperado do composer: + +- mic original: `segurar para transcrever` +- segundo mic: `clicar para comecar mensagem de voz` / `clicar novamente para enviar` + +Importante: + +- houve uma regressao em que a gravacao era cancelada no primeiro rerender +- a causa foi o cleanup do `useEffect` em `useVoiceInput` depender do objeto inteiro do recorder +- isso foi corrigido fazendo o cleanup depender apenas de `cancelRecording` estavel + +Arquivos relevantes: + +- `desktop/renderer/src/ui/chat/components/ChatComposer.tsx` +- `desktop/renderer/src/ui/chat/hooks/useVoiceInput.ts` + +### Voz OpenAI selecionavel + +O desktop agora permite trocar a voz OpenAI em `Settings > Voice`. + +Arquivo principal: + +- `desktop/renderer/src/ui/settings/voice/VoiceRecognitionTab.tsx` + +Catalogo fixo atual: + +- `alloy` +- `ash` +- `ballad` +- `coral` +- `echo` +- `sage` +- `shimmer` +- `verse` + +## Warnings Observados Em Dev + +Os logs recentes mostraram warnings e ruidos esperados de desenvolvimento, mas sem erro funcional bloqueante para a UI no estado atual. + +### Warnings aceitaveis / nao bloqueantes + +- `vite`: + - `Some chunks are larger than 500 kB after minification` + - e warning de bundle grande, nao quebra runtime +- `npm`: + - `Unknown env config "npm-globalconfig"` + - `verify-deps-before-run` + - `_jsr-registry` + - `allow-build` + - sao warnings de config herdada, nao falha funcional do app +- `Electron`: + - `'console-message' arguments are deprecated` + - e warning da API usada para forward de logs do renderer para o terminal +- `Chromium WebAudio`: + - `The ScriptProcessorNode is deprecated. Use AudioWorkletNode instead.` + - warning conhecido; a captura continua funcionando + +### O que nao apareceu como erro funcional no ultimo log + +No ultimo log enviado: + +- gateway subiu corretamente em `1515` +- renderer carregou (`dom-ready`) +- nao apareceu mais o `HTTP 400` da OpenAI transcription naquele trecho +- a UI de `voice mode` foi validada manualmente como funcionando + +### Melhorias futuras opcionais + +- migrar `useWavRecorder` de `ScriptProcessorNode` para `AudioWorkletNode` +- trocar o forward de logs do renderer para a API nova do Electron +- revisar chunking do bundle do renderer se performance de carregamento virar prioridade + +## Arquivos De Codigo Alterados + +### Desktop + +- `desktop/src/main/bootstrap/app-bootstrap.ts` +- `desktop/src/main/bootstrap/app-bootstrap.test.ts` +- `desktop/src/main/bootstrap/dev-global-gateway.ts` +- `desktop/src/main/bootstrap/dev-global-gateway.test.ts` +- `desktop/src/main/keys/apiKeys.ts` +- `desktop/src/main/keys/authProfilesStore.ts` +- `desktop/src/main/keys/authProfilesStore.test.ts` +- `desktop/src/main/analytics/posthog-main.ts` +- `desktop/src/main/analytics/posthog-main.test.ts` +- `desktop/renderer/src/analytics/posthog-client.ts` +- `desktop/renderer/src/analytics/posthog-client.test.ts` +- `desktop/renderer/src/store/slices/auth/authSlice.ts` +- `desktop/renderer/src/store/slices/auth/authSlice.test.ts` +- `desktop/renderer/src/store/slices/session-model-reset.ts` +- `desktop/renderer/src/ui/settings/account-models/AccountModelsTab.tsx` +- `desktop/renderer/src/ui/settings/account-models/AccountModelsTab.module.css` +- `desktop/renderer/src/ui/settings/account-models/AccountModelsTab.test.tsx` +- `desktop/renderer/src/ui/settings/providers/useModelProvidersState.ts` +- `desktop/src/main/gateway/config-migrations.ts` +- `desktop/src/main/gateway/config-migrations.test.ts` +- `desktop/src/main/gateway/spawn.ts` +- `desktop/src/main/gateway/spawn.test.ts` +- `desktop/src/main/ipc/file-reader.ts` +- `desktop/src/main/ipc/file-reader.test.ts` +- `desktop/src/main/keys/openai-api-key.ts` +- `desktop/src/main/keys/openai-api-key.test.ts` +- `desktop/src/main/whisper/ipc.ts` +- `desktop/src/main/whisper/ipc.test.ts` +- `desktop/src/preload.ts` +- `desktop/src/preload.test.ts` +- `desktop/renderer/index.html` +- `desktop/renderer/src/store/slices/chat/chat-types.ts` +- `desktop/renderer/src/store/slices/chat/chat-utils.ts` +- `desktop/renderer/src/store/slices/chat/chat-utils.test.ts` +- `desktop/renderer/src/store/slices/chat/chatSlice.ts` +- `desktop/renderer/src/store/slices/chat/chatSlice.test.ts` +- `desktop/renderer/src/store/slices/chat/chat-thunks.ts` +- `desktop/renderer/src/ui/chat/ChatPage.tsx` +- `desktop/renderer/src/ui/chat/components/ActionLog.tsx` +- `desktop/renderer/src/ui/chat/components/ActionLog.test.tsx` +- `desktop/renderer/src/ui/chat/components/ChatComposer.tsx` +- `desktop/renderer/src/ui/chat/components/ChatComposer.test.tsx` +- `desktop/renderer/src/ui/chat/components/ChatMessageList.tsx` +- `desktop/renderer/src/ui/chat/components/ChatMessageList.audio.test.tsx` +- `desktop/renderer/src/ui/chat/components/ToolCallCard.tsx` +- `desktop/renderer/src/ui/chat/components/ToolCallCard.test.tsx` +- `desktop/renderer/src/ui/chat/components/artifact-preview.ts` +- `desktop/renderer/src/ui/chat/components/inline-media.tsx` +- `desktop/renderer/src/ui/chat/context/ArtifactContext.tsx` +- `desktop/renderer/src/ui/chat/context/ArtifactContext.test.tsx` +- `desktop/renderer/src/ui/chat/hooks/useVoiceConfig.ts` +- `desktop/renderer/src/ui/chat/hooks/useVoiceConfig.test.tsx` +- `desktop/renderer/src/ui/chat/hooks/useVoiceInput.ts` +- `desktop/renderer/src/ui/chat/hooks/useVoiceInput.test.ts` +- `desktop/renderer/src/ui/settings/voice/VoiceRecognitionTab.tsx` +- `desktop/renderer/src/ui/settings/voice/VoiceRecognitionTab.module.css` +- `desktop/renderer/src/ui/settings/voice/VoiceRecognitionTab.test.tsx` + +### OpenClaw + +- `openclaw/src/gateway/server-methods/models.ts` +- `openclaw/src/gateway/server-methods/chat.ts` +- `openclaw/src/gateway/protocol/schema/sessions.ts` +- `openclaw/src/gateway/sessions-patch.ts` +- `openclaw/src/gateway/server.models-voicewake-misc.test.ts` +- `openclaw/src/gateway/server.chat.gateway-server-chat.test.ts` +- `openclaw/src/gateway/server.chat.gateway-server-chat-b.test.ts` +- `openclaw/src/gateway/server.sessions.gateway-server-sessions-a.test.ts` +- `openclaw/ui/src/ui/app.ts` +- `openclaw/ui/src/ui/controllers/exec-approval.ts` +- `openclaw/ui/src/ui/controllers/exec-approval.test.ts` +- `openclaw/ui/src/ui/app-gateway.sessions.node.test.ts` +- `openclaw/extensions/openai/speech-provider.ts` +- `openclaw/extensions/openai/speech-provider.test.ts` + +## Arquivos Locais Fora Do Repo + +Mudancas locais importantes feitas no ambiente: + +- `~/.openclaw/openclaw.json` +- `~/Library/LaunchAgents/ai.openclaw.gateway.plist` +- `~/Library/Application Support/openspace-desktop/openclaw/agents/main/agent/auth-profiles.json` + +Esses arquivos afetam runtime local, mas nao fazem parte do git do projeto. + +## Validacao Executada + +### Gateway / bootstrap + +- `pnpm exec vitest run src/main/bootstrap/dev-global-gateway.test.ts src/main/bootstrap/app-bootstrap.test.ts` +- `pnpm exec tsc -p tsconfig.json --noEmit` + +### Auth profiles / token sanitization + +- `pnpm exec vitest run src/main/keys/authProfilesStore.test.ts` +- `pnpm exec tsc -p tsconfig.json --noEmit` + +### Providers / account models + +- `pnpm exec vitest run renderer/src/store/slices/auth/authSlice.test.ts renderer/src/ui/settings/account-models/AccountModelsTab.test.tsx` +- `pnpm exec tsc -p tsconfig.json --noEmit` + +### PostHog + +- `pnpm exec vitest run renderer/src/analytics/posthog-client.test.ts src/main/analytics/posthog-main.test.ts` +- `pnpm exec tsc -p tsconfig.json --noEmit` + +### OpenClaw models.list + +- `pnpm exec vitest run --config vitest.config.ts src/gateway/server.models-voicewake-misc.test.ts` + +### Voice mode / chat renderer / inline media + +- `pnpm exec vitest run renderer/src/ui/chat/components/ActionLog.test.tsx renderer/src/ui/chat/components/ChatComposer.test.tsx renderer/src/ui/chat/components/ChatMessageList.audio.test.tsx renderer/src/ui/chat/components/ToolCallCard.test.tsx renderer/src/ui/chat/hooks/useVoiceConfig.test.tsx renderer/src/ui/chat/hooks/useVoiceInput.test.ts renderer/src/store/slices/chat/chat-utils.test.ts renderer/src/store/slices/chat/chatSlice.test.ts` +- `pnpm exec vitest run src/main/whisper/ipc.test.ts src/main/ipc/file-reader.test.ts src/main/gateway/spawn.test.ts src/main/gateway/config-migrations.test.ts` +- `pnpm exec tsc -p renderer/tsconfig.typecheck.json --noEmit` +- `pnpm exec tsc -p tsconfig.json --noEmit` + +## Procedimento Manual Esperado + +### Rodar desktop em dev + +Com o estado atual, `pnpm run dev:all` deve usar `1515` como gateway local do desktop. + +Se quiser subir o servico global depois: + +- `openclaw gateway restart` + +Se quiser operar so com o servico global: + +- nao use o desktop dev para o mesmo gateway ao mesmo tempo, ou deixe o desktop descarregar o `LaunchAgent` quando subir + +### Revalidar Anthropic subscription + +Se voltar a aparecer `401 Invalid bearer token`: + +1. conferir `auth-profiles.json` +2. validar se o token salvo nao tem whitespace +3. se estiver limpo e ainda falhar, gerar novo token porque o problema deixa de ser formacao local e passa a ser token invalido/expirado + +## Riscos E Observacoes + +- o `openclaw` continua com varios defaults/testes/documentacao historicos em `18789`; isso nao e a porta operacional desejada para o desktop atual +- o doc registra o estado real do ambiente local nesta data; parte dele depende de arquivos locais fora do git +- `openclaw` e submodulo, entao mudancas nele aparecem separadamente do repo raiz diff --git a/desktop/docs/release-secrets-checklist.md b/desktop/docs/release-secrets-checklist.md new file mode 100644 index 0000000000..71c02e5d01 --- /dev/null +++ b/desktop/docs/release-secrets-checklist.md @@ -0,0 +1,240 @@ +# OpenSpace Release Secrets Checklist + +Este guia cobre os valores esperados pela pipeline de release do Electron em: + +- [.github/workflows/build-desktop.yml](/Users/guilhermevarela/Documents/Projetos/openspace/.github/workflows/build-desktop.yml) +- [desktop/package.json](/Users/guilhermevarela/Documents/Projetos/openspace/desktop/package.json) +- [desktop/scripts/electron-builder.afterSign-notarize.cjs](/Users/guilhermevarela/Documents/Projetos/openspace/desktop/scripts/electron-builder.afterSign-notarize.cjs) +- [desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs](/Users/guilhermevarela/Documents/Projetos/openspace/desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs) + +## Onde configurar no GitHub + +No repositório `guilhermexp/openspace`: + +1. `Settings` +2. `Secrets and variables` +3. `Actions` + +Crie os itens abaixo. + +## Mínimo para auto-update funcionar + +Sem assinatura/notarização, o fluxo de auto-update já funciona se a release por tag conseguir publicar os assets no GitHub Release. + +`Secrets` + +- nenhum extra além do `GITHUB_TOKEN` padrão do Actions + +`Variables` + +- nenhuma obrigatória + +Resultado: + +- macOS publica `.zip`, `.blockmap`, `latest-mac.yml` +- Windows publica `.exe`, `.blockmap`, `latest.yml` +- download manual continua vindo de `.dmg` e `.exe` + +## Recomendado para release distribuível + +### 1. Code signing + +`Secrets` + +- `CSC_LINK` +- `CSC_KEY_PASSWORD` +- `CSC_NAME` + +### 2. Notarização macOS + +`Variable` + +- `OPENSPACE_NOTARIZE=1` + +`Secrets` + +- use `NOTARYTOOL_PROFILE` + +ou: + +- `NOTARYTOOL_KEY` +- `NOTARYTOOL_KEY_ID` +- `NOTARYTOOL_ISSUER` + +## O que cada secret faz + +`CSC_LINK` + +- certificado de assinatura que o `electron-builder` importa no runner + +`CSC_KEY_PASSWORD` + +- senha do arquivo do certificado + +`CSC_NAME` + +- nome exato da identidade +- exemplo comum: + `Developer ID Application: Guilherme Varela (TEAM_ID)` + +`NOTARYTOOL_PROFILE` + +- profile salvo no keychain para `xcrun notarytool` +- costuma ser mais chato de automatizar em CI hospedada + +`NOTARYTOOL_KEY` + +- conteúdo ou path da chave `.p8` de App Store Connect API key + +`NOTARYTOOL_KEY_ID` + +- Key ID da API key + +`NOTARYTOOL_ISSUER` + +- Issuer ID da App Store Connect API key + +`OPENSPACE_NOTARIZE` + +- se for `1`, os hooks de notarização rodam +- se estiver vazio ou ausente, assinatura pode ocorrer, mas notarização é pulada + +## Como gerar o `CSC_LINK` + +O caminho mais comum é exportar o certificado como `.p12` no Keychain do macOS e converter para base64. + +### Exportar o certificado + +No `Keychain Access`: + +1. localize o certificado `Developer ID Application` +2. exporte como `.p12` +3. defina uma senha forte + +### Converter para base64 + +```bash +base64 -i OpenSpace-DeveloperID.p12 | pbcopy +``` + +Cole o valor copiado em `CSC_LINK`. + +Se preferir o formato explícito: + +```text +data:application/x-pkcs12;base64, +``` + +Na prática, o `electron-builder` costuma aceitar o base64 direto. + +### Descobrir o `CSC_NAME` + +```bash +security find-identity -p codesigning -v +``` + +Procure a linha da identidade `Developer ID Application` e copie o nome completo entre aspas. + +## Como gerar os dados de notarização + +### Opção recomendada para CI: App Store Connect API key + +No Apple Developer / App Store Connect: + +1. crie uma API key para notarização +2. baixe o arquivo `.p8` +3. guarde: + - `KEY_ID` + - `ISSUER_ID` + - arquivo `.p8` + +No GitHub: + +- `NOTARYTOOL_KEY` + pode ser o conteúdo da `.p8` +- `NOTARYTOOL_KEY_ID` + valor do `KEY_ID` +- `NOTARYTOOL_ISSUER` + valor do `ISSUER_ID` + +Se quiser guardar o conteúdo da chave em base64 e reconstruir no workflow no futuro, isso também funciona, mas o workflow atual espera a variável já pronta para o `notarytool`. + +## Secrets opcionais que não bloqueiam release + +O pipeline também tolera ausência das credenciais do `gog`. + +Você só precisa disso se quiser empacotar o segredo OAuth do `gog`: + +- `OPENCLAW_GOG_OAUTH_CLIENT_SECRET_PATH` +- ou `OPENCLAW_GOG_OAUTH_CLIENT_SECRET_B64` +- ou `OPENCLAW_GOG_OAUTH_CLIENT_SECRET_JSON` + +Sem isso: + +- a build não quebra +- apenas o segredo do `gog` não é pré-embutido + +## Checklist de configuração + +Para colocar em produção com update automático: + +1. criar tag `vX.Y.Z` +2. garantir que o workflow publique no GitHub Release +3. verificar que a release draft contém: + - `.dmg` + - `.zip` + - `latest-mac.yml` + - `.exe` + - `latest.yml` + - `.blockmap` + +Para ficar assinado/notarizado: + +1. configurar `CSC_LINK` +2. configurar `CSC_KEY_PASSWORD` +3. configurar `CSC_NAME` +4. configurar `OPENSPACE_NOTARIZE=1` +5. configurar `NOTARYTOOL_KEY` ou `NOTARYTOOL_PROFILE` + +## Bootstrap automático via script + +Se você já tiver os arquivos locais, pode subir quase tudo com um comando: + +```bash +REPO=guilhermexp/openspace \ +CSC_P12_PATH=~/certs/OpenSpace-DeveloperID.p12 \ +CSC_KEY_PASSWORD='SUA_SENHA_DO_P12' \ +CSC_NAME='Developer ID Application: Seu Nome (TEAM_ID)' \ +NOTARYTOOL_KEY_PATH=~/certs/AuthKey_ABC123XYZ.p8 \ +NOTARYTOOL_KEY_ID='ABC123XYZ' \ +NOTARYTOOL_ISSUER='00000000-0000-0000-0000-000000000000' \ +OPENSPACE_NOTARIZE=1 \ +bash desktop/scripts/configure-github-release-secrets.sh +``` + +Script: + +- [configure-github-release-secrets.sh](/Users/guilhermevarela/Documents/Projetos/openspace/desktop/scripts/configure-github-release-secrets.sh) + +## Smoke test final + +Depois da primeira release: + +1. instale uma versão antiga localmente +2. publique uma versão nova com tag maior +3. abra o app empacotado +4. confirme que: + - o banner de update aparece + - o download acontece + - o restart instala a nova versão + +## Observação importante + +Hoje o app usa `GitHub provider` para updates. + +Isso significa: + +- você pode hospedar `DMG` e `EXE` no seu site para download manual +- mas o update automático continua lendo os assets do GitHub Release + +Se quiser mover o feed de update para seu site, o próximo passo é trocar de `GitHub provider` para `generic provider`. diff --git a/desktop/docs/telegram-manual-setup-electron.md b/desktop/docs/telegram-manual-setup-electron.md index 2a76ac3530..dfdc066fab 100644 --- a/desktop/docs/telegram-manual-setup-electron.md +++ b/desktop/docs/telegram-manual-setup-electron.md @@ -270,7 +270,7 @@ Formato relevante do snapshot em [types.ts](/Users/guilhermevarela/Documents/Pro type ConfigSnapshot = { hash?: string; config?: unknown; -} +}; ``` ### `config.patch` diff --git a/desktop/package.json b/desktop/package.json index 86074a3023..86f463f682 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -36,7 +36,7 @@ "fetch:gh": "node scripts/fetch-gh-runtime.mjs", "fetch:whisper-cli": "node scripts/fetch-whisper-cli-runtime.mjs", "build:memo": "node scripts/build-memo-runtime.mjs", - "prepare:gog:credentials": "node --env-file=.env scripts/prepare-gog-credentials.mjs", + "prepare:gog:credentials": "node --env-file-if-exists=.env scripts/prepare-gog-credentials.mjs", "prepare:gog": "node scripts/prepare-gog-runtime.mjs", "prepare:jq": "node scripts/prepare-jq-runtime.mjs", "prepare:memo": "node scripts/prepare-memo-runtime.mjs", @@ -54,11 +54,11 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p renderer/tsconfig.typecheck.json && tsc -p tsconfig.tools.json", "check:ci": "npm run lint && npm run prettier:check && npm run typecheck", "precommit": "npm run format:fix && npm run lint && npm run typecheck", - "dist:full": "npm run prepare:openclaw && npm run prepare:node && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist -- --publish never && open release/*.dmg", - "dist:local": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist -- --publish never", - "dist:local:win": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_BUILDER_COMPRESSION_LEVEL=1 npm run dist -- --publish never --win", + "dist:full": "npm run prepare:all && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist -- --publish never && open release/*.dmg", + "dist:local": "npm run prepare:all && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist -- --publish never", + "dist:local:win": "npm run prepare:all && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_BUILDER_COMPRESSION_LEVEL=1 npm run dist -- --publish never --win", "dist": "npm run build:all && electron-builder", - "dist:env": "npm run build:all && node --env-file=.env ./node_modules/electron-builder/out/cli/cli.js", + "dist:env": "npm run prepare:all && npm run build:all && node --env-file-if-exists=.env ./node_modules/electron-builder/out/cli/cli.js", "dist:env:local": "CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist:env -- --publish never", "release": "bash scripts/release.sh", "test": "vitest run --config vitest.config.ts", diff --git a/desktop/renderer/index.html b/desktop/renderer/index.html index eda8bea521..385ba59ead 100644 --- a/desktop/renderer/index.html +++ b/desktop/renderer/index.html @@ -6,7 +6,7 @@ OpenSpace diff --git a/desktop/renderer/src/analytics/posthog-client.test.ts b/desktop/renderer/src/analytics/posthog-client.test.ts new file mode 100644 index 0000000000..4de1cd45b8 --- /dev/null +++ b/desktop/renderer/src/analytics/posthog-client.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const posthogInit = vi.fn(); + +vi.mock("posthog-js", () => ({ + default: { + init: (...args: unknown[]) => posthogInit(...args), + capture: vi.fn(), + identify: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), + reset: vi.fn(), + __loaded: false, + has_opted_out_capturing: vi.fn(() => false), + }, +})); + +describe("posthog-client", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("does not initialize PostHog in the renderer", async () => { + const { initPosthogRenderer } = await import("./posthog-client"); + + initPosthogRenderer("user-1", true); + + expect(posthogInit).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/renderer/src/analytics/posthog-client.ts b/desktop/renderer/src/analytics/posthog-client.ts index 0e911e3490..45b048af56 100644 --- a/desktop/renderer/src/analytics/posthog-client.ts +++ b/desktop/renderer/src/analytics/posthog-client.ts @@ -1,97 +1,17 @@ -import posthog, { type CaptureResult, type Properties } from "posthog-js"; - -// Injected by Vite at build time from VITE_POSTHOG_API_KEY in .env. -const POSTHOG_API_KEY = import.meta.env.VITE_POSTHOG_API_KEY ?? ""; -const POSTHOG_HOST = "https://us.i.posthog.com"; -const GEOIP_DISABLE_PROPERTY = "$geoip_disable"; -const IP_PROPERTY = "$ip"; - -let initialized = false; let currentUserId: string | null = null; -function disableGeoipForEvent(event: CaptureResult | null): CaptureResult | null { - if (!event) { - return event; - } - - const properties: Properties = { - ...(event.properties ?? {}), - [GEOIP_DISABLE_PROPERTY]: true, - }; - - delete properties[IP_PROPERTY]; - - return { - ...event, - properties, - }; -} - -export function initPosthogRenderer(userId: string, enabled: boolean): void { - if (initialized) { - return; - } - initialized = true; +export function initPosthogRenderer(userId: string, _enabled: boolean): void { currentUserId = userId; - - posthog.init(POSTHOG_API_KEY, { - api_host: POSTHOG_HOST, - person_profiles: "identified_only", - // Disable automatic captures — only manual events. - autocapture: false, - capture_pageview: false, - capture_pageleave: false, - disable_session_recording: true, - property_denylist: [IP_PROPERTY], - before_send: (event) => disableGeoipForEvent(event), - loaded: (ph) => { - if (enabled) { - ph.identify(userId); - } else { - ph.opt_out_capturing(); - } - }, - }); } -/** Capture an event from the renderer. Safe to call before init or when opted out. */ -export function captureRenderer(event: string, properties?: Record): void { - try { - if (!posthog.__loaded || posthog.has_opted_out_capturing()) { - return; - } - posthog.capture(event, properties); - } catch { - // Never let analytics errors surface to the user. - } -} +export function captureRenderer(_event: string, _properties?: Record): void {} -/** Enable analytics for the renderer and identify the user. */ export function optInRenderer(userId: string): void { - try { - if (!posthog.__loaded) { - return; - } - currentUserId = userId; - posthog.opt_in_capturing(); - posthog.identify(userId); - } catch { - // Best-effort. - } + currentUserId = userId; } -/** Disable analytics for the renderer. */ export function optOutRenderer(): void { - try { - if (!posthog.__loaded) { - return; - } - posthog.opt_out_capturing(); - posthog.reset(); - currentUserId = null; - } catch { - // Best-effort. - } + currentUserId = null; } export function getCurrentUserId(): string | null { diff --git a/desktop/renderer/src/gateway/client.ts b/desktop/renderer/src/gateway/client.ts index c1b5ded13f..5b7d28b5a0 100644 --- a/desktop/renderer/src/gateway/client.ts +++ b/desktop/renderer/src/gateway/client.ts @@ -215,7 +215,7 @@ export class GatewayClient { }, caps: ["tool-events"], role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: ["operator.admin", "operator.approvals", "operator.pairing", "operator.write"], auth: { token: this.opts.token }, }; const frame: GatewayRequestFrame = { diff --git a/desktop/renderer/src/store/slices/auth/authSlice.test.ts b/desktop/renderer/src/store/slices/auth/authSlice.test.ts index 22f549e82e..51d9b5b8f3 100644 --- a/desktop/renderer/src/store/slices/auth/authSlice.test.ts +++ b/desktop/renderer/src/store/slices/auth/authSlice.test.ts @@ -594,6 +594,57 @@ describe("switchToSubscription thunk", () => { expect(mockBackendApi.getStatus).not.toHaveBeenCalled(); expect(store.getState().auth.jwt).toBeNull(); }); + + it("reloads secrets, clears session model overrides, and refreshes config", async () => { + const store = createTestStore(); + const mockRequest = vi.fn().mockImplementation((method: string, _params?: unknown) => { + if (method === "config.get") { + return Promise.resolve({ + config: { + auth: { + profiles: { "anthropic:default": { provider: "anthropic", mode: "api_key" } }, + order: { anthropic: ["anthropic:default"] }, + }, + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4.6" }, + models: { "anthropic/claude-sonnet-4.6": {} }, + }, + }, + }, + hash: "abc123", + exists: true, + }); + } + if (method === "config.patch") return Promise.resolve({ ok: true }); + if (method === "secrets.reload") return Promise.resolve({ ok: true }); + if (method === "sessions.list") { + return Promise.resolve({ + sessions: [ + { key: "one", modelOverride: "openai/gpt-4.1" }, + { key: "two", model: "anthropic/claude-sonnet-4.6" }, + { key: "three", modelProvider: "anthropic" }, + { key: "clean", model: null, modelOverride: null, modelProvider: null }, + ], + }); + } + if (method === "sessions.patch") return Promise.resolve({ ok: true }); + return Promise.resolve({}); + }); + + await store.dispatch(switchToSubscription({ request: mockRequest })).unwrap(); + + expect(mockRequest).toHaveBeenCalledWith("secrets.reload", {}); + expect(mockRequest).toHaveBeenCalledWith("sessions.list", { + includeGlobal: false, + includeUnknown: false, + }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "one", model: null }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "two", model: null }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "three", model: null }); + expect(store.getState().config.status).toBe("ready"); + expect(store.getState().config.snap?.hash).toBe("abc123"); + }); }); // ── switchToSelfManaged thunk ─────────────────────────────────────────────── @@ -731,6 +782,57 @@ describe("switchToSelfManaged thunk", () => { const backupAfterSecond = JSON.parse(storageMap.get(PAID_BACKUP_LS_KEY)!); expect(backupAfterSecond.authToken.jwt).toBe("jwt-first"); }); + + it("reloads secrets, clears session model overrides, and refreshes config", async () => { + const store = createTestStore(); + const mockRequest = vi.fn().mockImplementation((method: string, _params?: unknown) => { + if (method === "config.get") { + return Promise.resolve({ + config: { + auth: { + profiles: { "anthropic:default": { provider: "anthropic", mode: "api_key" } }, + order: { anthropic: ["anthropic:default"] }, + }, + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4.6" }, + models: { "anthropic/claude-sonnet-4.6": {} }, + }, + }, + }, + hash: "abc123", + exists: true, + }); + } + if (method === "config.patch") return Promise.resolve({ ok: true }); + if (method === "secrets.reload") return Promise.resolve({ ok: true }); + if (method === "sessions.list") { + return Promise.resolve({ + sessions: [ + { key: "one", modelOverride: "openai/gpt-4.1" }, + { key: "two", model: "anthropic/claude-sonnet-4.6" }, + { key: "three", modelProvider: "anthropic" }, + { key: "clean", model: null, modelOverride: null, modelProvider: null }, + ], + }); + } + if (method === "sessions.patch") return Promise.resolve({ ok: true }); + return Promise.resolve({}); + }); + + await store.dispatch(switchToSelfManaged({ request: mockRequest })).unwrap(); + + expect(mockRequest).toHaveBeenCalledWith("secrets.reload", {}); + expect(mockRequest).toHaveBeenCalledWith("sessions.list", { + includeGlobal: false, + includeUnknown: false, + }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "one", model: null }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "two", model: null }); + expect(mockRequest).toHaveBeenCalledWith("sessions.patch", { key: "three", model: null }); + expect(store.getState().config.status).toBe("ready"); + expect(store.getState().config.snap?.hash).toBe("abc123"); + }); }); // ── applySubscriptionKeys thunk ───────────────────────────────────────────── diff --git a/desktop/renderer/src/store/slices/auth/authSlice.ts b/desktop/renderer/src/store/slices/auth/authSlice.ts index f5119a84cf..94c28be429 100644 --- a/desktop/renderer/src/store/slices/auth/authSlice.ts +++ b/desktop/renderer/src/store/slices/auth/authSlice.ts @@ -41,10 +41,29 @@ import { normalizeAutoTopUpSettings, resetAuthFields, } from "./auth-utils"; +import { resetSessionModelSelection } from "../session-model-reset"; export type { SetupMode, AutoTopUpState, AuthSliceState, AuthRefreshReason } from "./auth-types"; export { persistMode } from "./auth-persistence"; +async function finalizeModeSwitch( + request: GatewayRequest, + dispatch: Parameters[0] extends never + ? never + : { + (action: T): T; + } +) { + try { + await request("secrets.reload", {}); + } catch (err) { + console.warn("[authSlice] Failed to reload secrets:", err); + } + + await resetSessionModelSelection(request); + await dispatch(reloadConfig({ request })); +} + const initialState: AuthSliceState = { mode: null, jwt: null, @@ -241,6 +260,7 @@ export const switchToSubscription = createAsyncThunk( await thunkApi.dispatch(authActions.setMode("paid")); persistMode("paid"); + await finalizeModeSwitch(request, thunkApi.dispatch); } ); @@ -343,6 +363,7 @@ export const switchToSelfManaged = createAsyncThunk( persistMode("self-managed"); clearBackup(); + await finalizeModeSwitch(request, thunkApi.dispatch); return { hasBackup: !!backup, diff --git a/desktop/renderer/src/store/slices/chat/chat-thunks.ts b/desktop/renderer/src/store/slices/chat/chat-thunks.ts index 7d204b82b7..e8c3db945d 100644 --- a/desktop/renderer/src/store/slices/chat/chat-thunks.ts +++ b/desktop/renderer/src/store/slices/chat/chat-thunks.ts @@ -42,11 +42,13 @@ export const sendChatMessage = createAsyncThunk( sessionKey, message, attachments, + systemProvenanceReceipt, }: { request: GatewayRequest; sessionKey: string; message: string; attachments?: ChatAttachmentInput[]; + systemProvenanceReceipt?: string; }, thunkApi ) => { @@ -112,6 +114,9 @@ export const sendChatMessage = createAsyncThunk( message: trimmed, deliver: false, idempotencyKey: runId, + ...(systemProvenanceReceipt?.trim() + ? { systemProvenanceReceipt: systemProvenanceReceipt.trim() } + : {}), ...(apiAttachments.length > 0 ? { attachments: apiAttachments } : {}), }); captureRenderer(ANALYTICS_EVENTS.messageSent); diff --git a/desktop/renderer/src/store/slices/chat/chat-types.ts b/desktop/renderer/src/store/slices/chat/chat-types.ts index 1ca5f59bd3..d9a3e4bfd4 100644 --- a/desktop/renderer/src/store/slices/chat/chat-types.ts +++ b/desktop/renderer/src/store/slices/chat/chat-types.ts @@ -2,6 +2,7 @@ export type UiMessageAttachment = { type: string; mimeType?: string; dataUrl?: string; + filePath?: string; }; /** A tool invocation extracted from assistant message content. */ @@ -17,6 +18,7 @@ export type UiToolResult = { toolName: string; text: string; status?: string; + audioPath?: string; /** Attachments (images/files) from the tool result content. */ attachments?: UiMessageAttachment[]; }; @@ -56,6 +58,8 @@ export type LiveToolCall = { phase: "start" | "update" | "result"; resultText?: string; isError?: boolean; + audioPath?: string; + attachments?: UiMessageAttachment[]; }; export type ChatSliceState = { diff --git a/desktop/renderer/src/store/slices/chat/chat-utils.test.ts b/desktop/renderer/src/store/slices/chat/chat-utils.test.ts index 579f452e80..b057594d85 100644 --- a/desktop/renderer/src/store/slices/chat/chat-utils.test.ts +++ b/desktop/renderer/src/store/slices/chat/chat-utils.test.ts @@ -9,6 +9,7 @@ import { extractAttachmentsFromMessage, extractText, extractToolCalls, + extractToolResult, isApprovalContinueMessage, isHeartbeatMessage, parseHistoryMessages, @@ -104,6 +105,20 @@ describe("extractAttachmentsFromMessage", () => { expect(result).toHaveLength(1); expect(result[0].type).toBe("image"); }); + + it("extracts audio attachments as data URLs", () => { + const result = extractAttachmentsFromMessage({ + content: [{ type: "audio", data: "abc123", mimeType: "audio/ogg" }], + }); + + expect(result).toEqual([ + { + type: "audio", + mimeType: "audio/ogg", + dataUrl: "data:audio/ogg;base64,abc123", + }, + ]); + }); }); // ── extractToolCalls ──────────────────────────────────────────────────────────── @@ -140,6 +155,92 @@ describe("extractToolCalls", () => { }); }); +// ── extractToolResult ─────────────────────────────────────────────────────────── + +describe("extractToolResult", () => { + it("extracts audioPath from tool results", () => { + const result = extractToolResult({ + role: "toolResult", + toolCallId: "tc-tts", + toolName: "tts", + content: [{ type: "text", text: "Generated audio reply." }], + details: { + status: "completed", + audioPath: "/tmp/reply.opus", + }, + }); + + expect(result).toEqual({ + toolCallId: "tc-tts", + toolName: "tts", + text: "Generated audio reply.", + status: "completed", + audioPath: "/tmp/reply.opus", + attachments: undefined, + }); + }); + + it("extracts generated image paths from tool result details media", () => { + const result = extractToolResult({ + role: "toolResult", + toolCallId: "tc-image", + toolName: "image_generate", + content: [{ type: "text", text: "Generated 1 image with openai/gpt-image-1." }], + details: { + media: { + mediaUrls: ["/tmp/generated/world-2029.png"], + }, + paths: ["/tmp/generated/world-2029.png"], + }, + }); + + expect(result).toEqual({ + toolCallId: "tc-image", + toolName: "image_generate", + text: "Generated 1 image with openai/gpt-image-1.", + status: undefined, + audioPath: undefined, + attachments: [ + { + type: "image", + mimeType: "image/png", + filePath: "/tmp/generated/world-2029.png", + }, + ], + }); + }); + + it("extracts generated media from singular detail path fields", () => { + const result = extractToolResult({ + role: "toolResult", + toolCallId: "tc-browser", + toolName: "browser", + content: [{ type: "text", text: "Captured screenshot." }], + details: { + path: "/tmp/generated/capture.jpg", + media: { + mediaUrl: "/tmp/generated/capture.jpg", + }, + }, + }); + + expect(result).toEqual({ + toolCallId: "tc-browser", + toolName: "browser", + text: "Captured screenshot.", + status: undefined, + audioPath: undefined, + attachments: [ + { + type: "image", + mimeType: "image/jpeg", + filePath: "/tmp/generated/capture.jpg", + }, + ], + }); + }); +}); + // ── isHeartbeatMessage ────────────────────────────────────────────────────────── describe("isHeartbeatMessage", () => { @@ -251,4 +352,16 @@ describe("parseHistoryMessages", () => { expect(result[0].toolResults).toHaveLength(1); expect(result[0].toolResults![0].toolCallId).toBe("tc-1"); }); + + it("hides assistant NO_REPLY placeholder messages defensively", () => { + const raw = [ + { role: "assistant", content: "NO_REPLY", timestamp: 1000 }, + { role: "assistant", content: "resposta real", timestamp: 2000 }, + ]; + + const result = parseHistoryMessages(raw); + + expect(result).toHaveLength(1); + expect(result[0].text).toBe("resposta real"); + }); }); diff --git a/desktop/renderer/src/store/slices/chat/chat-utils.ts b/desktop/renderer/src/store/slices/chat/chat-utils.ts index 26a0bd340a..7d6c01a99a 100644 --- a/desktop/renderer/src/store/slices/chat/chat-utils.ts +++ b/desktop/renderer/src/store/slices/chat/chat-utils.ts @@ -90,18 +90,127 @@ export function extractAttachmentsFromMessage(msg: unknown): UiMessageAttachment mimeType = mediaType; } } + if ( + !dataUrl && + type === "audio" && + (typeof part.data === "string" || typeof part.source?.data === "string") + ) { + const data = + typeof part.data === "string" + ? part.data + : typeof part.source?.data === "string" + ? part.source.data + : undefined; + const mediaType = + typeof part.mimeType === "string" + ? part.mimeType + : typeof part.source?.media_type === "string" + ? part.source.media_type + : "audio/mpeg"; + if (data) { + dataUrl = `data:${mediaType};base64,${data}`; + mimeType = mediaType; + } + } out.push({ type: type || "file", mimeType: mimeType || (typeof part.mimeType === "string" ? part.mimeType : undefined), dataUrl, }); } + + if (out.length === 0) { + const details = (msg as { details?: unknown }).details; + const derived = extractAttachmentsFromToolDetails(details); + if (derived.length > 0) { + out.push(...derived); + } + } } catch { // ignore } return out; } +function inferAttachmentType(pathOrUrl: string): UiMessageAttachment["type"] { + const mimeType = inferMimeTypeFromPath(pathOrUrl); + if (mimeType.startsWith("image/")) { + return "image"; + } + if (mimeType.startsWith("audio/")) { + return "audio"; + } + if (mimeType.startsWith("video/")) { + return "video"; + } + return "file"; +} + +function inferMimeTypeFromPath(pathOrUrl: string): string { + const normalized = pathOrUrl.split("?")[0]?.toLowerCase() ?? ""; + if (normalized.endsWith(".png")) return "image/png"; + if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg"; + if (normalized.endsWith(".webp")) return "image/webp"; + if (normalized.endsWith(".gif")) return "image/gif"; + if (normalized.endsWith(".svg")) return "image/svg+xml"; + if (normalized.endsWith(".mp3")) return "audio/mpeg"; + if (normalized.endsWith(".wav")) return "audio/wav"; + if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) return "audio/ogg"; + if (normalized.endsWith(".m4a")) return "audio/mp4"; + if (normalized.endsWith(".mp4")) return "video/mp4"; + if (normalized.endsWith(".webm")) return "video/webm"; + if (normalized.endsWith(".pdf")) return "application/pdf"; + return "application/octet-stream"; +} + +function extractAttachmentsFromToolDetails(details: unknown): UiMessageAttachment[] { + if (!details || typeof details !== "object") { + return []; + } + + const typed = details as { + path?: unknown; + paths?: unknown; + media?: { mediaUrl?: unknown; mediaUrls?: unknown }; + }; + const rawPaths: string[] = []; + + if (typeof typed.path === "string" && typed.path.trim()) { + rawPaths.push(typed.path); + } + + if (Array.isArray(typed.paths)) { + for (const candidate of typed.paths) { + if (typeof candidate === "string" && candidate.trim()) { + rawPaths.push(candidate); + } + } + } + + if (typeof typed.media?.mediaUrl === "string" && typed.media.mediaUrl.trim()) { + rawPaths.push(typed.media.mediaUrl); + } + + const mediaUrls = typed.media?.mediaUrls; + if (Array.isArray(mediaUrls)) { + for (const candidate of mediaUrls) { + if (typeof candidate === "string" && candidate.trim()) { + rawPaths.push(candidate); + } + } + } + + const uniquePaths = [...new Set(rawPaths)]; + return uniquePaths.map((filePath) => { + const mimeType = inferMimeTypeFromPath(filePath); + return { + type: inferAttachmentType(filePath), + mimeType, + filePath, + }; + }); +} + const HEARTBEAT_PROMPT_PREFIX = "Read HEARTBEAT.md if it exists (workspace context)."; const HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"; @@ -192,7 +301,7 @@ export function extractToolResult(msg: unknown): UiToolResult | null { toolCallId?: string; toolName?: string; content?: unknown; - details?: { status?: string }; + details?: { status?: string; audioPath?: string }; }; const role = typeof m.role === "string" ? m.role : ""; if (role !== "toolResult" && role !== "tool_result") { @@ -205,6 +314,7 @@ export function extractToolResult(msg: unknown): UiToolResult | null { toolName: typeof m.toolName === "string" ? m.toolName : "unknown", text, status: typeof m.details?.status === "string" ? m.details.status : undefined, + audioPath: typeof m.details?.audioPath === "string" ? m.details.audioPath : undefined, attachments: attachments.length > 0 ? attachments : undefined, }; } @@ -252,6 +362,9 @@ export function parseHistoryMessages(raw: unknown[]): UiMessage[] { } // Strip gateway-injected metadata so the UI shows only the actual message content. const displayText = text ? stripMetadata(text).trim() : ""; + if (role === "assistant" && displayText === "NO_REPLY") { + continue; + } const ts = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? Math.floor(msg.timestamp) diff --git a/desktop/renderer/src/store/slices/chat/chatSlice.test.ts b/desktop/renderer/src/store/slices/chat/chatSlice.test.ts index 6a7d3097f1..39ff91b4d0 100644 --- a/desktop/renderer/src/store/slices/chat/chatSlice.test.ts +++ b/desktop/renderer/src/store/slices/chat/chatSlice.test.ts @@ -244,6 +244,61 @@ describe("chatSlice reducers", () => { const state = chatReducer(withStream, chatActions.streamCleared({ runId: "r1" })); expect(state.streamByRun["r1"]).toBeUndefined(); }); + + it("preserves live tool audio results when finalizing a run", () => { + let state = chatReducer( + base, + chatActions.toolCallStarted({ + toolCallId: "tc-tts", + runId: "run-audio", + name: "tts", + arguments: { text: "fala" }, + }) + ); + + state = chatReducer( + state, + chatActions.toolCallFinished({ + toolCallId: "tc-tts", + resultText: "Generated audio reply.", + audioPath: "/tmp/reply.opus", + attachments: [ + { + type: "audio", + mimeType: "audio/ogg", + dataUrl: "data:audio/ogg;base64,abc123", + }, + ], + }) + ); + + state = chatReducer( + state, + chatActions.streamFinalReceived({ + runId: "run-audio", + seq: 1, + text: "", + }) + ); + + expect(state.messages).toHaveLength(1); + expect(state.messages[0].toolResults).toEqual([ + { + toolCallId: "tc-tts", + toolName: "tts", + text: "Generated audio reply.", + status: undefined, + audioPath: "/tmp/reply.opus", + attachments: [ + { + type: "audio", + mimeType: "audio/ogg", + dataUrl: "data:audio/ogg;base64,abc123", + }, + ], + }, + ]); + }); }); // ── Pure helpers ──────────────────────────────────────────────────────────────── @@ -508,4 +563,27 @@ describe("sendChatMessage thunk", () => { expect(state.error).toContain("network error"); expect(state.sending).toBe(false); }); + + it("forwards the hidden system provenance receipt when provided", async () => { + const store = createTestStore(); + const mockRequest = vi.fn().mockResolvedValue({}); + + await store.dispatch( + sendChatMessage({ + request: mockRequest, + sessionKey: "s1", + message: "hello", + systemProvenanceReceipt: " voice-loop ", + }) + ); + + expect(mockRequest).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "s1", + message: "hello", + systemProvenanceReceipt: "voice-loop", + }) + ); + }); }); diff --git a/desktop/renderer/src/store/slices/chat/chatSlice.ts b/desktop/renderer/src/store/slices/chat/chatSlice.ts index bb40365cda..e17ea7926b 100644 --- a/desktop/renderer/src/store/slices/chat/chatSlice.ts +++ b/desktop/renderer/src/store/slices/chat/chatSlice.ts @@ -209,6 +209,8 @@ const chatSlice = createSlice({ toolName: ltc.name, text: ltc.resultText, status: ltc.isError ? "error" : undefined, + audioPath: ltc.audioPath, + attachments: ltc.attachments, }); } delete state.liveToolCalls[key]; @@ -275,6 +277,8 @@ const chatSlice = createSlice({ toolCallId: string; resultText?: string; isError?: boolean; + audioPath?: string; + attachments?: UiMessageAttachment[]; }> ) { const entry = state.liveToolCalls[action.payload.toolCallId]; @@ -282,6 +286,8 @@ const chatSlice = createSlice({ entry.phase = "result"; entry.resultText = action.payload.resultText; entry.isError = action.payload.isError; + entry.audioPath = action.payload.audioPath; + entry.attachments = action.payload.attachments; } }, /** Clear all live tool calls for a given runId (e.g. when the run finishes). */ diff --git a/desktop/renderer/src/store/slices/session-model-reset.ts b/desktop/renderer/src/store/slices/session-model-reset.ts new file mode 100644 index 0000000000..9e81631e20 --- /dev/null +++ b/desktop/renderer/src/store/slices/session-model-reset.ts @@ -0,0 +1,25 @@ +import type { GatewayRequest } from "./chat/chatSlice"; + +export async function resetSessionModelSelection(request: GatewayRequest): Promise { + try { + const result = await request<{ + sessions?: Array<{ + key?: string; + modelOverride?: string | null; + model?: string | null; + modelProvider?: string | null; + }>; + }>("sessions.list", { includeGlobal: false, includeUnknown: false }); + const sessions = result.sessions ?? []; + const dirtySessions = sessions.filter( + (session): session is { key: string } => + typeof session.key === "string" && + Boolean(session.modelOverride || session.model || session.modelProvider) + ); + await Promise.all( + dirtySessions.map((session) => request("sessions.patch", { key: session.key, model: null })) + ); + } catch { + // Best effort: new sessions will still use the updated default model. + } +} diff --git a/desktop/renderer/src/ui/chat/ChatPage.tsx b/desktop/renderer/src/ui/chat/ChatPage.tsx index cd58b933a9..e4585f9999 100644 --- a/desktop/renderer/src/ui/chat/ChatPage.tsx +++ b/desktop/renderer/src/ui/chat/ChatPage.tsx @@ -13,21 +13,38 @@ import { import { upgradePaywallActions } from "@store/slices/upgradePaywallSlice"; import type { GatewayState } from "@main/types"; import { HIDDEN_TOOL_NAMES } from "./components/ToolCallCard"; +import { ArtifactDivider } from "./components/ArtifactDivider"; +import { ArtifactPanel } from "./components/ArtifactPanel"; import { ChatComposer, type ChatComposerRef } from "./components/ChatComposer"; import { ChatMessageList } from "./components/ChatMessageList"; import { ScrollToBottomButton } from "./components/ScrollToBottomButton"; +import { clampArtifactPanelWidth } from "./components/artifact-preview"; +import { ArtifactProvider, useArtifact } from "./context/ArtifactContext"; import { useOptimisticSession } from "./hooks/optimisticSessionContext"; import { useChatStream } from "./hooks/useChatStream"; import { useMarkdownComponents } from "./hooks/useMarkdownComponents"; +import { useVoiceInput } from "./hooks/useVoiceInput"; import { useVoiceConfig } from "./hooks/useVoiceConfig"; import { addToastError } from "@shared/toast"; import ct from "./ChatTranscript.module.css"; -export function ChatPage({ state: _state }: { state: Extract }) { +const ARTIFACT_PANEL_BREAKPOINT = 960; +const VOICE_REPLY_RECEIPT = + "Voice mode is active for this session. After composing your normal user-visible reply, use the tts tool to generate spoken audio for that same reply. Continue doing this on every turn until voice mode is turned off."; + +type SessionVoiceModeListResult = { + sessions?: Array<{ + key: string; + ttsAuto?: string | null; + }>; +}; + +function ChatPageContent({ state: _state }: { state: Extract }) { const [searchParams] = useSearchParams(); const sessionKey = searchParams.get("session") ?? ""; const [input, setInput] = React.useState(""); const [attachments, setAttachments] = React.useState([]); + const artifact = useArtifact(); const { optimistic, setOptimistic } = useOptimisticSession(); const optimisticFirstMessage = optimistic?.key === sessionKey ? (optimistic.firstMessage ?? null) : null; @@ -59,6 +76,11 @@ export function ChatPage({ state: _state }: { state: Extract(null); const composerRef = React.useRef(null); + const shellRef = React.useRef(null); + const [viewportWidth, setViewportWidth] = React.useState(() => + typeof window === "undefined" ? 1280 : window.innerWidth + ); + const [voiceReplyMode, setVoiceReplyMode] = React.useState(false); const scrollToBottom = React.useCallback((behavior: ScrollBehavior = "smooth") => { console.log(behavior, "behavior"); @@ -69,8 +91,8 @@ export function ChatPage({ state: _state }: { state: Extract { if (optimisticFirstMessage === null) { @@ -100,6 +122,35 @@ export function ChatPage({ state: _state }: { state: Extract { + if (!gw.connected || !sessionKey) { + setVoiceReplyMode(false); + return; + } + let cancelled = false; + void gw + .request("sessions.list", { + includeGlobal: true, + includeUnknown: true, + limit: 200, + }) + .then((res) => { + if (cancelled) { + return; + } + const session = res.sessions?.find((entry) => entry.key === sessionKey); + setVoiceReplyMode(session?.ttsAuto === "always"); + }) + .catch(() => { + if (!cancelled) { + setVoiceReplyMode(false); + } + }); + return () => { + cancelled = true; + }; + }, [gw, sessionKey]); + React.useEffect(() => { const id = requestAnimationFrame(() => composerRef.current?.focusInput()); return () => cancelAnimationFrame(id); @@ -172,6 +223,23 @@ export function ChatPage({ state: _state }: { state: Extract { + if (voiceMessageInput.error) { + addToastError(voiceMessageInput.error); + } + }, [voiceMessageInput.error]); + + React.useEffect(() => { + const handleResize = () => { + setViewportWidth(window.innerWidth); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + const send = React.useCallback(() => { if (sending || hasActiveStream) { return; @@ -189,7 +257,13 @@ export function ChatPage({ state: _state }: { state: Extract - ["displayMessages"] - } - streamByRun={streamByRun} - liveToolCalls={liveToolCalls} - optimisticFirstMessage={optimisticFirstMessage} - optimisticFirstAttachments={optimisticFirstAttachments} - matchingFirstUserFromHistory={ - matchingFirstUserFromHistory as React.ComponentProps< - typeof ChatMessageList - >["matchingFirstUserFromHistory"] - } - waitingForFirstResponse={waitingForFirstResponse} - markdownComponents={markdownComponents} - scrollRef={scrollRef} - /> + const toggleVoiceReplyMode = React.useCallback( + async (nextEnabled: boolean) => { + if (!sessionKey) { + return; + } + try { + const res = await gw.request<{ entry?: { ttsAuto?: string | null } }>("sessions.patch", { + key: sessionKey, + ttsAuto: nextEnabled ? "always" : "off", + }); + setVoiceReplyMode((res.entry?.ttsAuto ?? null) === "always"); + } catch (err) { + addToastError(err); + } + }, + [gw, sessionKey] + ); + + const voiceConfig = useVoiceConfig(gw.request, composerRef, setInput); + + const handleVoiceMessageStart = React.useCallback(() => { + voiceMessageInput.startRecording(); + }, [voiceMessageInput]); + + const handleVoiceMessageStop = React.useCallback(async () => { + const text = await voiceMessageInput.stopRecording(); + const message = text?.trim() ?? ""; + if (!message || !sessionKey || sending || hasActiveStream) { + return; + } + if (needsUpgradePaywall) { + dispatch(upgradePaywallActions.open()); + return; + } + + if (!voiceReplyMode) { + setVoiceReplyMode(true); + void gw + .request<{ entry?: { ttsAuto?: string | null } }>("sessions.patch", { + key: sessionKey, + ttsAuto: "always", + }) + .then((res) => { + setVoiceReplyMode((res.entry?.ttsAuto ?? null) === "always"); + }) + .catch((err) => { + setVoiceReplyMode(false); + addToastError(err); + }); + } + + void dispatch( + sendChatMessage({ + request: gw.request, + sessionKey, + message, + systemProvenanceReceipt: VOICE_REPLY_RECEIPT, + }) + ); + + requestAnimationFrame(() => composerRef.current?.focusInput()); + }, [ + dispatch, + gw, + hasActiveStream, + needsUpgradePaywall, + sending, + sessionKey, + voiceMessageInput, + voiceReplyMode, + ]); + + const showArtifactPanel = artifact.filePath != null && viewportWidth >= ARTIFACT_PANEL_BREAKPOINT; + + React.useEffect(() => { + if (!showArtifactPanel) { + return; + } + const containerWidth = shellRef.current?.clientWidth ?? 0; + const nextPanelWidth = clampArtifactPanelWidth(artifact.panelWidth, containerWidth); + if (nextPanelWidth !== artifact.panelWidth) { + artifact.setPanelWidth(nextPanelWidth); + } + }, [artifact.panelWidth, artifact.setPanelWidth, showArtifactPanel, viewportWidth]); -
- +
+ ["displayMessages"] + } + streamByRun={streamByRun} + liveToolCalls={liveToolCalls} + optimisticFirstMessage={optimisticFirstMessage} + optimisticFirstAttachments={optimisticFirstAttachments} + matchingFirstUserFromHistory={ + matchingFirstUserFromHistory as React.ComponentProps< + typeof ChatMessageList + >["matchingFirstUserFromHistory"] + } + waitingForFirstResponse={waitingForFirstResponse} + markdownComponents={markdownComponents} scrollRef={scrollRef} - onScroll={scrollToBottom} - contentKey={displayMessages.length} + voiceReplyMode={voiceReplyMode} + onVoiceReplyModeToggle={toggleVoiceReplyMode} /> - addToastError(msg)} - isVoiceRecording={voiceConfig.voice.isRecording} - isVoiceProcessing={voiceConfig.voice.isProcessing} - onVoiceStart={voiceConfig.handleVoiceStart} - onVoiceStop={voiceConfig.handleVoiceStop} - voiceNotConfigured={voiceConfig.voiceConfigured === false} - onNavigateVoiceSettings={voiceConfig.handleNavigateVoiceSettings} - whisperDownload={voiceConfig.whisperDownload} - onWhisperDownload={voiceConfig.handleWhisperDownload} - /> +
+ + + addToastError(msg)} + isVoiceRecording={voiceConfig.voice.isRecording} + isVoiceProcessing={voiceConfig.voice.isProcessing} + onVoiceStart={voiceConfig.handleVoiceStart} + onVoiceStop={voiceConfig.handleVoiceStop} + isVoiceMessageRecording={voiceMessageInput.isRecording} + isVoiceMessageProcessing={voiceMessageInput.isProcessing} + onVoiceMessageStart={handleVoiceMessageStart} + onVoiceMessageStop={handleVoiceMessageStop} + voiceNotConfigured={voiceConfig.voiceConfigured === false} + onNavigateVoiceSettings={voiceConfig.handleNavigateVoiceSettings} + whisperDownload={voiceConfig.whisperDownload} + onWhisperDownload={voiceConfig.handleWhisperDownload} + isAgentActive={sending || hasActiveStream} + voiceReplyMode={voiceReplyMode} + onVoiceReplyModeToggle={toggleVoiceReplyMode} + /> +
+ {showArtifactPanel ? : null} + {showArtifactPanel ? : null}
); } + +export function ChatPage({ state }: { state: Extract }) { + return ( + + + + ); +} diff --git a/desktop/renderer/src/ui/chat/ChatTranscript.module.css b/desktop/renderer/src/ui/chat/ChatTranscript.module.css index 734383e9dc..f47cedb530 100644 --- a/desktop/renderer/src/ui/chat/ChatTranscript.module.css +++ b/desktop/renderer/src/ui/chat/ChatTranscript.module.css @@ -1,6 +1,14 @@ +.UiChatShellWithArtifact { + display: flex; + flex-direction: row; + height: 100%; + min-width: 0; +} + /* Chat shell and transcript container */ .UiChatShell { - min-width: 500px; + flex: 1 1 auto; + min-width: 400px; height: 100%; display: flex; flex-direction: column; diff --git a/desktop/renderer/src/ui/chat/components/ActionLog.test.tsx b/desktop/renderer/src/ui/chat/components/ActionLog.test.tsx new file mode 100644 index 0000000000..22e5b07739 --- /dev/null +++ b/desktop/renderer/src/ui/chat/components/ActionLog.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ActionLog } from "./ActionLog"; + +vi.mock("./ToolCallCard", () => ({ + ToolCallCard: ({ toolCall }: { toolCall: { name: string } }) => ( +
{toolCall.name}
+ ), + LiveToolCallCardItem: ({ tc }: { tc: { name: string } }) => ( +
{tc.name}
+ ), + getToolLabel: (name: string) => name, + HIDDEN_TOOL_NAMES: new Set(), +})); + +describe("ActionLog", () => { + const cards = [ + { + toolCall: { + id: "tool-1", + name: "search", + arguments: {}, + }, + }, + ]; + + it("starts expanded by default", () => { + cleanup(); + render(); + + expect(screen.getByRole("button", { name: /action log/i }).getAttribute("aria-expanded")).toBe( + "true" + ); + expect(screen.getByTestId("tool-call-card")).toBeTruthy(); + }); + + it("starts collapsed in voice mode and can be expanded manually", () => { + cleanup(); + render(); + + const header = screen.getByRole("button", { name: /action log/i }); + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByTestId("tool-call-card")).toBeNull(); + + fireEvent.click(header); + + expect(header.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTestId("tool-call-card")).toBeTruthy(); + }); + + it("collapses when voice mode is turned on", () => { + cleanup(); + const { rerender } = render(); + + const header = screen.getByRole("button", { name: /action log/i }); + fireEvent.click(header); + expect(header.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(header); + expect(header.getAttribute("aria-expanded")).toBe("true"); + + rerender(); + + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByTestId("tool-call-card")).toBeNull(); + }); +}); diff --git a/desktop/renderer/src/ui/chat/components/ActionLog.tsx b/desktop/renderer/src/ui/chat/components/ActionLog.tsx index 2c2ecb052e..1d31fa1a68 100644 --- a/desktop/renderer/src/ui/chat/components/ActionLog.tsx +++ b/desktop/renderer/src/ui/chat/components/ActionLog.tsx @@ -10,15 +10,27 @@ export type ActionLogCard = { toolCall: UiToolCall; result?: UiToolResult }; export function ActionLog({ cards = [], liveToolCalls = [], + voiceReplyMode = false, + autoCollapse = false, + onVoiceReplyModeToggle, }: { cards?: ActionLogCard[]; liveToolCalls?: LiveToolCall[]; + voiceReplyMode?: boolean; + autoCollapse?: boolean; + onVoiceReplyModeToggle?: (next: boolean) => void; }) { const visibleLive = liveToolCalls.filter((tc) => !HIDDEN_TOOL_NAMES.has(tc.name)); const hasLive = visibleLive.length > 0; - const [expanded, setExpanded] = React.useState(true); + const [expanded, setExpanded] = React.useState(!autoCollapse); const title = hasLive ? getToolLabel(visibleLive[visibleLive.length - 1].name) : "Action Log"; + React.useEffect(() => { + if (autoCollapse) { + setExpanded(false); + } + }, [autoCollapse]); + return (
- +
))} diff --git a/desktop/renderer/src/ui/chat/components/ArtifactDivider.tsx b/desktop/renderer/src/ui/chat/components/ArtifactDivider.tsx new file mode 100644 index 0000000000..b9889594d4 --- /dev/null +++ b/desktop/renderer/src/ui/chat/components/ArtifactDivider.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { useArtifact } from "../context/ArtifactContext"; +import { clampArtifactPanelWidth } from "./artifact-preview"; +import styles from "./ArtifactPanel.module.css"; + +export function ArtifactDivider({ + containerRef, +}: { + containerRef: React.RefObject; +}) { + const { setPanelWidth } = useArtifact(); + const [isDragging, setIsDragging] = React.useState(false); + const cleanupRef = React.useRef<(() => void) | null>(null); + + const stopDragging = React.useCallback(() => { + cleanupRef.current?.(); + cleanupRef.current = null; + setIsDragging(false); + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("cursor"); + }, []); + + React.useEffect(() => stopDragging, [stopDragging]); + + const handleMouseDown = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setIsDragging(true); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + const nextWidth = rect.right - moveEvent.clientX; + setPanelWidth(clampArtifactPanelWidth(nextWidth, rect.width)); + }; + + const handleMouseUp = () => { + stopDragging(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + cleanupRef.current = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, + [containerRef, setPanelWidth, stopDragging] + ); + + return ( +
+ ); +} diff --git a/desktop/renderer/src/ui/chat/components/ArtifactPanel.module.css b/desktop/renderer/src/ui/chat/components/ArtifactPanel.module.css new file mode 100644 index 0000000000..1794c91f9f --- /dev/null +++ b/desktop/renderer/src/ui/chat/components/ArtifactPanel.module.css @@ -0,0 +1,224 @@ +.ArtifactPanel { + display: flex; + flex-direction: column; + height: 100%; + min-width: 300px; + background: var(--bg-base, #0d1117); + border-left: 1px solid rgba(255, 255, 255, 0.05); + overflow: hidden; + animation: artifactPanelEnter 200ms ease; +} + +.ArtifactHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.ArtifactTitleGroup { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ArtifactFileName { + font-size: 13px; + line-height: 1.4; + font-weight: 600; + color: rgba(255, 255, 255, 0.96); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ArtifactFilePath { + font-size: 11px; + line-height: 1.4; + color: rgba(255, 255, 255, 0.45); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ArtifactHeaderActions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ArtifactIconButton { + appearance: none; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + line-height: 1; + cursor: pointer; + padding: 6px; + border-radius: 6px; + transition: + color 120ms ease, + background 120ms ease; +} + +.ArtifactIconButton:hover { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +.ArtifactContent { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +.ArtifactState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + flex: 1 1 auto; + min-height: 0; + padding: 24px; + text-align: center; + color: rgba(255, 255, 255, 0.65); +} + +.ArtifactRetryButton { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + line-height: 1; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; +} + +.ArtifactMarkdown { + padding: 20px 24px 32px; +} + +.ArtifactCodeBlock { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding: 16px; +} + +.ArtifactCodeHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: none; + border-radius: 12px 12px 0 0; +} + +.ArtifactCodeLanguage { + font-size: 12px; + line-height: 1.4; + color: rgba(255, 255, 255, 0.58); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ArtifactCodePre { + margin: 0; + padding: 16px; + background: #0b1220; + color: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0 0 12px 12px; + overflow: auto; + flex: 1 1 auto; + min-height: 0; + font-size: 12px; + line-height: 1.55; +} + +.ArtifactCodePre code, +.ArtifactText { + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; +} + +.ArtifactText { + margin: 0; + padding: 20px 24px 32px; + white-space: pre-wrap; + word-break: break-word; + color: rgba(255, 255, 255, 0.85); + font-size: 12px; + line-height: 1.6; +} + +.ArtifactImageWrap { + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.05), transparent 48%), + var(--bg-base, #0d1117); +} + +.ArtifactImage { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.ArtifactPdf, +.ArtifactVideo, +.ArtifactHtml { + width: 100%; + height: 100%; + border: 0; + background: #0b0f14; +} + +.ArtifactVideo { + object-fit: contain; +} + +.ArtifactDivider { + width: 4px; + flex: 0 0 4px; + cursor: col-resize; + background: rgba(255, 255, 255, 0.06); + transition: background 120ms ease; +} + +.ArtifactDivider:hover { + background: rgba(255, 255, 255, 0.15); +} + +.ArtifactDivider--active { + background: var(--accent, #71717a); +} + +@keyframes artifactPanelEnter { + from { + opacity: 0; + transform: translateX(14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/desktop/renderer/src/ui/chat/components/ArtifactPanel.test.tsx b/desktop/renderer/src/ui/chat/components/ArtifactPanel.test.tsx new file mode 100644 index 0000000000..acb6b4afd0 --- /dev/null +++ b/desktop/renderer/src/ui/chat/components/ArtifactPanel.test.tsx @@ -0,0 +1,16 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; + +import { getArtifactRenderKind } from "./ArtifactPanel"; + +describe("getArtifactRenderKind", () => { + it("detects markdown, code, media, html, and plain text previews", () => { + expect(getArtifactRenderKind("/tmp/readme.md")).toBe("markdown"); + expect(getArtifactRenderKind("/tmp/component.tsx")).toBe("code"); + expect(getArtifactRenderKind("/tmp/image.png")).toBe("image"); + expect(getArtifactRenderKind("/tmp/report.pdf")).toBe("pdf"); + expect(getArtifactRenderKind("/tmp/demo.mp4")).toBe("video"); + expect(getArtifactRenderKind("/tmp/index.html")).toBe("html"); + expect(getArtifactRenderKind("/tmp/notes.txt")).toBe("text"); + }); +}); diff --git a/desktop/renderer/src/ui/chat/components/ArtifactPanel.tsx b/desktop/renderer/src/ui/chat/components/ArtifactPanel.tsx new file mode 100644 index 0000000000..24f76f3e9b --- /dev/null +++ b/desktop/renderer/src/ui/chat/components/ArtifactPanel.tsx @@ -0,0 +1,185 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import { CopyCodeButton } from "./CopyCodeButton"; +import { useArtifact } from "../context/ArtifactContext"; +import { useMarkdownComponents } from "../hooks/useMarkdownComponents"; +import { + getArtifactFileName, + getArtifactLanguageLabel, + getArtifactRenderKind, + toArtifactFileUrl, +} from "./artifact-preview"; +import { openExternal } from "@shared/utils/openExternal"; +import styles from "./ArtifactPanel.module.css"; + +export { getArtifactRenderKind } from "./artifact-preview"; + +function ArtifactContent() { + const { filePath, fileContent } = useArtifact(); + const markdownComponents = useMarkdownComponents(); + + if (!filePath) { + return null; + } + + const renderKind = getArtifactRenderKind(filePath); + const fileUrl = toArtifactFileUrl(filePath); + + if (renderKind === "markdown") { + return ( +
+
+ + {fileContent ?? ""} + +
+
+ ); + } + + if (renderKind === "code") { + return ( +
+
+
+ + {getArtifactLanguageLabel(filePath)} + + +
+
+            {fileContent ?? ""}
+          
+
+
+ ); + } + + if (renderKind === "image") { + return ( +
+
+ {getArtifactFileName(filePath)} +
+
+ ); + } + + if (renderKind === "pdf") { + return ( +
+ +
+ ); + } + + if (renderKind === "video") { + return ( +
+
+ ); + } + + if (renderKind === "audio") { + return ( +
+
+ ); + } + + if (renderKind === "html") { + return ( +
+