From b6148a5cb45e70a75c055a57f8b269f24c619a6a Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 17 Aug 2026 14:29:34 +0800 Subject: [PATCH] fix: enable voice input for fresh agent conversations --- .../20260817-voice-input-fix-b4e62d91.md | 50 ++++++++ src/pages/Chat/OpencodeChatPanel.tsx | 2 +- tests/unit/opencode-chat-panel.test.tsx | 118 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .project-docs/30-worklog/tasks/20260817-voice-input-fix-b4e62d91.md diff --git a/.project-docs/30-worklog/tasks/20260817-voice-input-fix-b4e62d91.md b/.project-docs/30-worklog/tasks/20260817-voice-input-fix-b4e62d91.md new file mode 100644 index 0000000..6bcfc9e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260817-voice-input-fix-b4e62d91.md @@ -0,0 +1,50 @@ +# Task: Fix Code fresh-conversation voice input + +## Identity + +- Task ID: 20260817-voice-input-fix-b4e62d91 +- Mode: Feature +- Branch: codex/20260817-voice-input-fix-b4e62d91-voice-input-fix +- Worktree: D:\Datas\OthersProjects\makelore-voice-input-fix-b4e62d91 +- Base commit: 9fd9a7761d3872f25e853ac1b059e55251c6ae8f +- Owner: codex +- Status: Completed + +## Scope + +- Enable AI Programming voice capture after the user selects an Agent but before the lazy first OpenCode session exists. +- Add a component-level regression that exercises the real fresh-contact state and verifies transcription populates the composer draft without creating an empty session. +- Preserve existing-session voice behavior and the current busy/transcribing guards. + +## Intent And Constraints + +- Preserve lazy contact selection: choosing an unused Agent or starting voice capture must not create or start an OpenCode session; only message submission may create the first session. +- Keep speech capture and transcription on the existing Renderer -> Host API -> Electron Main -> Works Square path. +- Do not change Canvas, Learning, Agent templates, provider configuration, or unrelated composer behavior. +- Keep the implementation surgical and limited to the ChatPanel predicate and its focused unit coverage. + +## Outcome + +- Updated the AI Programming composer voice-availability predicate so a selected Agent is sufficient before the lazy first session exists. +- Kept the existing loading, transcription, recording, and session-busy guards unchanged. +- Added a component regression covering a fresh Agent conversation: voice capture transcribes into the draft while the selected session remains lazy and no session-creation request is sent. + +## Verification + +- Regression-first evidence: the new fresh-Agent test failed before the predicate change because the voice button was disabled, then passed after the one-line fix. +- `pnpm exec eslint src/pages/Chat/OpencodeChatPanel.tsx tests/unit/opencode-chat-panel.test.tsx` passed. +- Five focused lazy-session and voice-input tests passed (`5 passed`, `80 skipped`). +- The complete `opencode-chat-panel.test.tsx` file passed (`85/85`) during implementation. +- `pnpm test` passed (`175` files, `2048` tests). +- `pnpm run typecheck` passed. +- `pnpm run build:vite` passed for Renderer, Electron Main, and Preload (`3921`, `133`, and `1` modules transformed respectively); only pre-existing chunk-size and mixed static/dynamic import warnings were reported. +- Final read-only Sol review: `PASS`, with no blocking findings across spec and standards checks. +- Electron microphone E2E was not added because the shared fixture does not provide a deterministic `MediaRecorder`/microphone seam; the component regression exercises Agent selection, recording start/stop, transcription, and lazy-session behavior directly. + +## Follow-ups + +- None. + +## Promotion Candidates + +- None. The change restores the documented lazy-session behavior and does not alter an architectural or product contract. diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index c156d7e..3f0acfe 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -1040,7 +1040,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro ); const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed; const canUseVoiceInput = canUseSessions - && Boolean(selectedSessionId) + && Boolean(selectedSessionId || selectedAgentId) && !loading && voiceInputState !== 'transcribing' && (!sessionBusy || voiceInputState === 'recording'); diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 2560f1b..083ce39 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -1010,6 +1010,124 @@ describe('OpencodeChatPanel', () => { expect(screen.queryByText('课程槽位')).not.toBeInTheDocument(); }); + it('records voice into a fresh Agent conversation without eagerly creating a session', async () => { + installMockVoiceWavTranscoder(); + const activeProject = { + id: 'prj_fresh_voice', + path: 'D:/repo/fresh-voice', + name: 'fresh-voice', + createdAt: '2026-08-17T00:00:00.000Z', + updatedAt: '2026-08-17T00:00:00.000Z', + lastOpenedAt: '2026-08-17T00:00:00.000Z', + }; + const config = createConfiguredProjectConfig(); + const getUserMedia = vi.fn().mockResolvedValue({ + getTracks: vi.fn(() => [{ stop: vi.fn() }]), + }); + + class MockMediaRecorder { + public ondataavailable: ((event: { data: Blob }) => void) | null = null; + public onstop: (() => void) | null = null; + public state: RecordingState = 'inactive'; + + static isTypeSupported(): boolean { + return true; + } + + start(): void { + this.state = 'recording'; + } + + stop(): void { + this.state = 'inactive'; + this.ondataavailable?.({ data: new Blob(['audio bytes'], { type: 'audio/webm' }) }); + this.onstop?.(); + } + } + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia }, + }); + Object.defineProperty(window, 'MediaRecorder', { + configurable: true, + value: MockMediaRecorder, + }); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + accessToken: 'access-token', + refreshToken: null, + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'student', + userId: '42', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + useProjectConfigStore.setState({ + configsByProjectId: { [activeProject.id]: config }, + }); + useOpencodeStore.setState({ + status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }, + projects: [activeProject], + activeProject, + sessions: [], + selectedSessionId: null, + sessionStatuses: {}, + sessionMessages: [], + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path === '/api/works/speech/transcriptions' && init?.method === 'POST') { + return { + success: true, + transcription: { + text: 'voice before first message', + model: 'gpt-4o-mini-transcribe', + }, + }; + } + if (path === '/api/opencode/status') { + return { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }; + } + if (path === '/api/opencode/projects') { + return { projects: [activeProject], activeProject }; + } + if (path === '/api/opencode/config-summary') { + return { summary: { model: 'openai/gpt-5', smallModel: null, providerIds: ['openai'], providerCount: 1 } }; + } + if (path === '/api/opencode/sessions') return { sessions: [] }; + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + throw new Error(`Unexpected path ${path}`); + }); + + render(); + + fireEvent.click(await screen.findByTestId('project-agent-chat-game-art')); + await waitFor(() => { + expect(screen.getByTestId('project-agent-chat-game-art')).toHaveAttribute('aria-pressed', 'true'); + }); + expect(useOpencodeStore.getState().selectedSessionId).toBeNull(); + + const voiceButton = screen.getByTestId('opencode-voice-input-button'); + expect(voiceButton).toBeEnabled(); + fireEvent.click(voiceButton); + await waitFor(() => expect(getUserMedia).toHaveBeenCalledWith({ audio: true })); + fireEvent.click(voiceButton); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('voice before first message'); + }); + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/opencode/sessions' + && (init as RequestInit | undefined)?.method === 'POST' + ))).toBe(false); + }); + it('keeps project-agent actions enabled when a pre-start status response is delayed', async () => { const activeProject = { id: 'prj_delayed_status', path: 'D:/repo/delayed-status', name: 'delayed-status', createdAt: '2026-07-12T00:00:00.000Z', updatedAt: '2026-07-12T00:00:00.000Z', lastOpenedAt: '2026-07-12T00:00:00.000Z' }; const config = createConfiguredProjectConfig();