Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable user-facing changes to PageSpace are documented here. Format follows

## [Unreleased]

### Added

- **A microphone in the top bar, on every page, that talks to whichever assistant you are already
looking at** — there is no separate voice screen and nothing to set up first. Press it on a page
and the assistant sidebar opens in voice mode, talking to the agent you had selected there; press
it on the dashboard and it talks to the assistant already in the middle of your screen. Nothing
connects until you press it.
- **A spoken conversation is the same conversation you can read and type in** — voice is a way into
a thread that already existed and still exists after you hang up. What is said appears in that
thread as ordinary messages while the call is running, marked with a small microphone so you can
tell later what was spoken and what was typed. There is no separate voice history to go looking
for, and nothing to replay.
- **The call survives you walking around the app** — moving between pages does not end it or move it
to a different assistant; it just tells the assistant where you now are. Closing the sidebar
minimizes the call rather than hanging up, and the top-bar microphone stays lit so you can get
back to it. Deliberately choosing a different agent in the sidebar's switcher does move the call,
because that is a different conversation. Ending a call is the End button on the call itself, and
refreshing the page ends it too.
- **When voice cannot start, it says which problem you have** — a microphone you declined is
different from a microphone you do not have, and the two now get different advice and only the
fixable one offers to try again. If the call connects but the transcript service does not, the
call says so rather than letting you talk for ten minutes into something that was never going to
be saved.

### Fixed

- **A second agent in a session stays put instead of flashing up and vanishing** — opening a chat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ConversationMessage, TextPart } from './message-types';
import { isTextGroupPart, isProcessedToolPart, isFileGroupPart, isCommandExecutionPart, isToolRunGroupPart } from './message-types';
import { CommandExecutionIndicator } from '@/components/messages/CommandExecutionIndicator';
import { ImageMessageContent } from './ImageMessageContent';
import { SpokenTurnGlyph, isSpokenTurn } from './SpokenTurnGlyph';
import styles from './CompactMessageRenderer.module.css';

interface CompactTextBlockProps {
Expand All @@ -30,6 +31,8 @@ interface CompactTextBlockProps {
onCancelEdit?: () => void;
/** Whether this message is currently being streamed (for progressive markdown rendering) */
isStreaming?: boolean;
/** Turn was spoken into a live voice call, not typed. */
spoken?: boolean;
}

/**
Expand All @@ -49,7 +52,8 @@ const CompactTextBlock: React.FC<CompactTextBlockProps> = React.memo(({
isEditing,
onSaveEdit,
onCancelEdit,
isStreaming = false
isStreaming = false,
spoken = false
}) => {
const content = parts.map(part => part.text).join('');

Expand Down Expand Up @@ -90,13 +94,14 @@ const CompactTextBlock: React.FC<CompactTextBlockProps> = React.memo(({
</div>
{/* Always show footer with buttons; timestamp only when createdAt exists */}
<div className="flex items-center justify-between mt-1">
<div className="text-[10px] text-gray-500">
<div className="flex items-center gap-1 text-[10px] text-gray-500">
{createdAt && (
<>
{new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{editedAt && <span className="ml-1">(edited)</span>}
</>
)}
{spoken && <SpokenTurnGlyph />}
</div>
{onEdit && onDelete && !isEditing && (
<MessageActionButtons
Expand Down Expand Up @@ -262,6 +267,7 @@ export const CompactMessageRenderer: React.FC<CompactMessageRendererProps> = Rea
onSaveEdit={handleSaveEdit}
onCancelEdit={() => setIsEditing(false)}
isStreaming={isStreaming}
spoken={isSpokenTurn(message)}
/>
);
} else if (isFileGroupPart(group)) {
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/ai/shared/chat/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useMessageRendererState } from './useMessageRendererState';
import type { ConversationMessage, TextPart } from './message-types';
import { isTextGroupPart, isProcessedToolPart, isFileGroupPart, isCommandExecutionPart, isToolRunGroupPart } from './message-types';
import { ImageMessageContent } from './ImageMessageContent';
import { SpokenTurnGlyph, isSpokenTurn } from './SpokenTurnGlyph';
import { CommandExecutionIndicator } from '@/components/messages/CommandExecutionIndicator';

interface TextBlockProps {
Expand All @@ -31,6 +32,8 @@ interface TextBlockProps {
onCancelEdit?: () => void;
/** Whether this message is currently being streamed (for progressive markdown rendering) */
isStreaming?: boolean;
/** Turn was spoken into a live voice call, not typed. */
spoken?: boolean;
}

/**
Expand All @@ -49,7 +52,8 @@ const TextBlock: React.FC<TextBlockProps> = React.memo(({
isEditing,
onSaveEdit,
onCancelEdit,
isStreaming = false
isStreaming = false,
spoken = false
}) => {
const content = parts.map(part => part.text).join('');

Expand Down Expand Up @@ -89,13 +93,14 @@ const TextBlock: React.FC<TextBlockProps> = React.memo(({
</div>
{/* Always show footer with buttons; timestamp only when createdAt exists */}
<div className="flex items-center justify-between mt-2">
<div className="text-xs text-gray-500">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
{createdAt && (
<>
{new Date(createdAt).toLocaleTimeString()}
{editedAt && <span className="ml-2">(edited)</span>}
</>
)}
{spoken && <SpokenTurnGlyph />}
</div>
{onEdit && onDelete && !isEditing && (
<MessageActionButtons
Expand Down Expand Up @@ -267,6 +272,7 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(({
onSaveEdit={handleSaveEdit}
onCancelEdit={() => setIsEditing(false)}
isStreaming={isStreaming}
spoken={isSpokenTurn(message)}
/>
);
} else if (isFileGroupPart(group)) {
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/ai/shared/chat/SpokenTurnGlyph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { Mic } from 'lucide-react';

import { cn } from '@/lib/utils';
import { VOICE_MESSAGE_SOURCE } from '@/lib/ai/realtime/message-source';
import type { ConversationMessage } from './message-types';

/**
* The mark on a turn that was SPOKEN rather than typed.
*
* WHY THE THREAD HAS TO SAY. Audio is ephemeral and the transcript is the
* artifact of record — so what lands in the conversation is all that survives a
* call, and it lands next to typed messages that look exactly like it. A
* dictated sentence reads differently: it runs long, it repeats itself, it
* carries whatever the transcriber heard. A reader who does not know it was
* spoken reads it as someone typing carelessly, and an AGENT re-reading the
* thread has the same problem. One glyph is the whole fix.
*
* Compared through the named constant rather than a `'voice'` literal: it
* exists precisely so the UI's check, the realtime writer and the seed builder
* cannot end up on three spellings of one string (see `message-source.ts` for
* why the browser reads its own copy, and where the drift guard lives).
*/
export const isSpokenTurn = (message: Pick<ConversationMessage, 'source'>): boolean =>
message.source === VOICE_MESSAGE_SOURCE;

export const SpokenTurnGlyph: React.FC<{ className?: string }> = ({ className }) => (
<span
data-testid="spoken-turn-glyph"
title="Spoken in a voice call"
className={cn('inline-flex items-center', className)}
>
<Mic className="h-3 w-3" aria-hidden="true" />
{/* Named for screen readers, which get nothing from an icon. */}
<span className="sr-only">Spoken in a voice call</span>
</span>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* A spoken turn is marked in the thread, in BOTH renderers.
*
* The transcript is the artifact of record — it is all that survives a call —
* and it lands beside typed messages that look exactly like it. A dictated
* sentence runs long, repeats itself, and carries whatever the transcriber
* heard; read as typing it looks careless. The glyph is the whole fix, and it
* has to be in both renderers because a conversation opened in the sidebar and
* the same conversation opened in the main view are the same conversation.
*/

import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { MESSAGE_SOURCE_VOICE } from '@pagespace/db/schema/conversations';
import type { ConversationMessage } from '../message-types';

vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: { id: 'u1', name: 'Me' } }) }));
vi.mock('@/hooks/useTasks', () => ({ useTasks: () => ({ tasks: [], isLoading: false }) }));

import { CompactMessageRenderer } from '../CompactMessageRenderer';
import { MessageRenderer } from '../MessageRenderer';

const message = (source: string | null): ConversationMessage =>
({
id: 'm1',
role: 'user',
parts: [{ type: 'text', text: 'what is on this page' }],
createdAt: new Date('2026-01-01T10:00:00Z'),
messageType: 'standard',
source,
}) as ConversationMessage;

describe.each([
['CompactMessageRenderer', CompactMessageRenderer],
['MessageRenderer', MessageRenderer],
])('%s — marking a spoken turn', (_name, Renderer) => {
it('should mark a voice-authored row', () => {
render(<Renderer message={message(MESSAGE_SOURCE_VOICE)} />);
expect(screen.getByTestId('spoken-turn-glyph')).toBeInTheDocument();
// Named for screen readers, which get nothing from an icon.
expect(screen.getByText('Spoken in a voice call')).toBeInTheDocument();
});

it('should leave a typed row unmarked', () => {
render(<Renderer message={message(null)} />);
expect(screen.queryByTestId('spoken-turn-glyph')).not.toBeInTheDocument();
});

it('should not mark a row that merely mentions voice', () => {
// The check is on the transport column, not on anything in the content.
render(<Renderer message={{ ...message('typed'), source: 'typed' }} />);
expect(screen.queryByTestId('spoken-turn-glyph')).not.toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions apps/web/src/components/ai/shared/chat/message-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export interface ConversationMessage extends UIMessage {
* read as `'complete'` server-side by default.
*/
status?: 'streaming' | 'complete' | 'interrupted';
/**
* The TRANSPORT this row was authored over (see `messages.source`) —
* `'voice'` for a turn spoken into a live realtime call, null/absent for a
* typed one.
*
* Rendered as a mic glyph rather than left invisible because a thread that
* mixes spoken and typed turns without saying which is which is a thread that
* misrepresents itself: the phrasing, the length and the errors of a spoken
* sentence are all different, and a reader who does not know it was dictated
* reads it as sloppy typing. Compared through {@link MESSAGE_SOURCE_VOICE},
* never a bare string literal.
*/
source?: string | null;
}

/**
Expand Down
Loading