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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Fragment, type ReactNode } from 'react';

import { highlightRunChildren } from '@/components/pr-review/diff/highlight-runs';
import { type HighlightToken } from '@/lib/pr-review/diff/highlight';
import { type TokenScheme } from '@/lib/pr-review/diff/syntax-colors';

/**
* A blank source line still occupies its line box; an `RNText` whose only
Expand All @@ -28,13 +29,13 @@ const CODE_LINE_BREAK = '\n';
*/
function renderLineRuns(
tokens: readonly HighlightToken[],
isDark: boolean,
scheme: TokenScheme,
keepBlankLineBox: boolean
): ReactNode {
if (keepBlankLineBox && tokens.every(token => token.text.length === 0)) {
return BLANK_CODE_LINE;
}
return highlightRunChildren(tokens, isDark);
return highlightRunChildren(tokens, scheme);
}

/**
Expand All @@ -47,13 +48,13 @@ function renderLineRuns(
*/
export function renderChunkChildren(
chunkLines: readonly (readonly HighlightToken[])[],
isDark: boolean,
scheme: TokenScheme,
keepBlankLineBox: boolean
): ReactNode[] {
return chunkLines.map((tokens, lineIndex) => (
<Fragment key={`line-${lineIndex}`}>
{lineIndex > 0 ? CODE_LINE_BREAK : null}
{renderLineRuns(tokens, isDark, keepBlankLineBox)}
{renderLineRuns(tokens, scheme, keepBlankLineBox)}
</Fragment>
));
}
16 changes: 14 additions & 2 deletions apps/mobile/src/components/agents/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ScrollView } from 'react-native-gesture-handler';

import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { type TokenScheme } from '@/lib/pr-review/diff/syntax-colors';

import { useTranscriptTextSelectable } from './bubble-text-selection-context';
import { renderChunkChildren } from './code-block-chunk-content';
Expand Down Expand Up @@ -42,6 +43,13 @@ type CodeBlockProps = {
The markdown renderer passes palette.textColor so code inside user
variant bubbles keeps its designed ink color (lime/primary surfaces). */
baseColor?: string;
/**
* Token scheme for the syntax-highlighted runs, derived by the caller from
* the card the block is painted on (`tokenSchemeForSurface`). The markdown
* renderer passes its palette's scheme; tool cards omit it and fall back to
* the app color scheme, since they render on the app `card` surface.
*/
tokenScheme?: TokenScheme;
/**
* When provided, a single tap on the block reveals an inline "Copy" action
* that hands the full source (before the display cap) back to the caller.
Expand Down Expand Up @@ -154,6 +162,7 @@ function CodeBlockImpl({
maxLength,
selectable,
baseColor,
tokenScheme,
onCopyCode,
onLongPressCode,
}: Readonly<CodeBlockProps>) {
Expand All @@ -168,6 +177,9 @@ function CodeBlockImpl({
// background-token equality: the generated palette can change, and the
// tokens would silently flip against their surface.
const isDark = useColorScheme() === 'dark';
// Tool cards render on the app `card` surface, whose brightness tracks the
// app scheme; the markdown renderer passes the scheme of its own code card.
const effectiveTokenScheme = tokenScheme ?? (isDark ? 'onDark' : 'onLight');
const { displayText, isTruncated } = prepareMonoScrollContent(code, maxLength);
const tokenLines = useMemo(
() => tokenizeCodeLines(displayText, language),
Expand Down Expand Up @@ -374,7 +386,7 @@ function CodeBlockImpl({
// eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- base ink for untagged runs
style={{ color: textBase }}
>
{renderChunkChildren(chunk, isDark, keepBlankLineBox)}
{renderChunkChildren(chunk, effectiveTokenScheme, keepBlankLineBox)}
</RNText>
))}
</View>
Expand All @@ -387,7 +399,7 @@ function CodeBlockImpl({
copyAccessibilityActions,
canCopyCode,
handleCopyAccessibilityAction,
isDark,
effectiveTokenScheme,
]
);

Expand Down
25 changes: 23 additions & 2 deletions apps/mobile/src/components/agents/markdown-palette.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { type MarkedStyles } from 'react-native-marked';

import { type ThemeColors } from '@/lib/hooks/use-theme-colors';
import {
compositeOver,
type TokenScheme,
tokenSchemeForSurface,
} from '@/lib/pr-review/diff/syntax-colors';

export type MarkdownVariant = 'assistant' | 'kilo-chat-user' | 'user';

Expand All @@ -12,6 +17,13 @@ export type MarkdownPalette = {
// The bubble surface this palette's ink is designed to sit on. Needed when
// palette-colored content renders outside its bubble (e.g. the table modal).
surfaceColor: string;
// The syntax token scheme for code fences in this variant, derived from the
// real code-card surface — the bubble tinted by `codeBackground`
// (`compositeOver`), classified by `tokenSchemeForSurface` — rather than the
// app color scheme: a dark-theme user bubble is bright lime while a
// light-theme kilo-chat bubble is dark olive. Optional so hand-written test
// palettes can omit it and fall back to the app scheme in `CodeBlock`.
codeTokenScheme?: TokenScheme;
};

// Derive a translucent variant of a theme token so we can tint dividers and
Expand All @@ -38,23 +50,31 @@ export function getPalette(variant: MarkdownVariant, colors: ThemeColors): Markd
if (variant === 'kilo-chat-user') {
// kilo-chat user bubbles sit on bg-primary; use primary-foreground ink.
const ink = colors.primaryForeground;
const codeBackground = withAlpha(ink, 0.1);
return {
textColor: ink,
mutedTextColor: withAlpha(ink, 0.7),
codeBackground: withAlpha(ink, 0.1),
codeBackground,
borderColor: withAlpha(ink, 0.2),
surfaceColor: colors.primary,
// Classify the real code card, not the bare bubble: the 10% ink tint is
// enough to move the card (the light-theme white tint lifts the olive
// primary #4F5A10 to #616B28, where the `dark` token values must clear
// 4.5:1 to stay legible).
codeTokenScheme: tokenSchemeForSurface(compositeOver(codeBackground, colors.primary)),
};
}
if (variant === 'user') {
// Agent chat user bubbles sit on accent-soft (lime); use ink-on-lime.
const ink = colors.accentSoftForeground;
const codeBackground = withAlpha(ink, 0.1);
return {
textColor: ink,
mutedTextColor: withAlpha(ink, 0.7),
codeBackground: withAlpha(ink, 0.1),
codeBackground,
borderColor: withAlpha(ink, 0.2),
surfaceColor: colors.accentSoft,
codeTokenScheme: tokenSchemeForSurface(compositeOver(codeBackground, colors.accentSoft)),
};
}
return {
Expand All @@ -63,6 +83,7 @@ export function getPalette(variant: MarkdownVariant, colors: ThemeColors): Markd
codeBackground: colors.muted,
borderColor: colors.border,
surfaceColor: colors.card,
codeTokenScheme: tokenSchemeForSurface(colors.muted),
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/markdown-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class MarkdownRenderer extends Renderer {
language: normalizeFenceLanguage(language),
selectable: this.selectable,
baseColor: this.palette.textColor,
tokenScheme: this.palette.codeTokenScheme,
maxLength: MARKDOWN_CODE_CHARACTER_CAP,
onCopyCode: this.onCopyCode,
onLongPressCode: this.onLongPressCode,
Expand Down
74 changes: 68 additions & 6 deletions apps/mobile/src/components/agents/message-details-copy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,74 @@
import { performCopy } from './use-message-copy';
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

/**
* Details-sheet Copy path: immediate shared `performCopy`, no ActionSheet.
* Kept free of RN UI so unit tests can pin the wiring.
* Details-sheet Copy path: immediate clipboard write, no ActionSheet, and no
* app-root toast. The sheet is a full-window RN Modal on Android — a separate
* native layer above the app-root Toaster — so a sonner toast never becomes
* visible while it is open. `MessageDetailsSheet` renders the outcome inline
* from this boolean instead.
*/
export function handleMessageDetailsCopy(copyableText: string | null | undefined): void {
export async function handleMessageDetailsCopy(
copyableText: string | null | undefined
): Promise<boolean> {
if (!copyableText) {
return;
return false;
}
void performCopy(copyableText);
try {
const copied = await Clipboard.setStringAsync(copyableText);
if (!copied) {
return false;
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
return true;
} catch {
return false;
}
}

type CopyFeedbackState = 'idle' | 'copied' | 'failed';

function copyStatusLabel(state: CopyFeedbackState, t: (key: string) => string): string | null {
if (state === 'copied') {
return t('common.copiedToClipboard');
}
if (state === 'failed') {
return t('common.couldNotCopyToClipboard');
}
return null;
}

/**
* Inline Copy outcome for the details sheet. sonner toasts render in the app
* root, behind this Modal's own native window on Android, so the sheet shows
* the outcome itself instead of relying on the toast (the same P2 pattern the
* context sheet's copy rows use). Closing the sheet or switching messages
* clears the feedback and invalidates a pending copy, so a reopen starts from
* the call to action even if an earlier copy finishes late.
*/
export function useMessageDetailsCopyFeedback(visible: boolean, messageId: string | null) {
const { t } = useTranslation();
const [state, setState] = useState<CopyFeedbackState>('idle');
const generation = useRef(0);
useEffect(() => {
setState('idle');
return () => {
generation.current += 1;
};
}, [visible, messageId]);
return {
state,
status: copyStatusLabel(state, t),
handleCopy: (copyText: string | null | undefined) => {
void (async () => {
const current = generation.current;
const success = await handleMessageDetailsCopy(copyText);
if (current === generation.current) {
setState(success ? 'copied' : 'failed');
}
})();
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Text } from '@/components/ui/text';

import { MessageDetailsSheet } from './message-details-sheet';

const native = vi.hoisted(() => ({ clipboard: '', announce: vi.fn() }));
const native = vi.hoisted(() => ({ clipboard: '', copyFails: false, announce: vi.fn() }));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ background: '#000', mutedForeground: '#999' }),
}));
Expand Down Expand Up @@ -72,8 +72,13 @@ vi.mock('@/components/ui/selectable-text', () => ({
SelectableText: 'SelectableText',
}));
vi.mock('expo-clipboard', () => ({
setStringAsync: (text: string) => {
setStringAsync: async (text: string) => {
if (native.copyFails) {
throw new Error('clipboard unavailable');
}
native.clipboard = text;
await Promise.resolve();
return true;
},
}));
vi.mock('expo-haptics', () => ({
Expand All @@ -84,6 +89,7 @@ vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } })

beforeEach(() => {
native.clipboard = '';
native.copyFails = false;
native.announce.mockClear();
vi.mocked(Alert.alert).mockClear();
});
Expand Down Expand Up @@ -383,6 +389,65 @@ describe('MessageDetailsSheet mounted', () => {
await unmount(renderer);
});

it('shows inline Copy success and failure feedback and clears it on close', async () => {
const message = storedMessage(assistantInfo(), [textPart('copy this response')]);
const renderer = await mountSheet(message);
const feedback = () => findByTestID(renderer.root, 'message-details-copy-feedback')[0];

expect(feedback()).toBeUndefined();

await act(async () => {
press(findByTestID(renderer.root, 'message-details-copy')[0]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(native.clipboard).toBe('copy this response');
expect(feedback()?.props.children).toBe('Copied to clipboard');

native.copyFails = true;
await act(async () => {
press(findByTestID(renderer.root, 'message-details-copy')[0]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(feedback()?.props.children).toBe('Could not copy to clipboard');

// Closing clears the inline status so a reopen starts from the call to action.
await act(async () => {
renderer.update(sheetElement(message, { visible: false }));
await Promise.resolve();
});
expect(feedback()).toBeUndefined();

await unmount(renderer);
});

it('clears inline Copy feedback when the message switches', async () => {
const renderer = await mountSheet(storedMessage(assistantInfo(), [textPart('first reply')]));

await act(async () => {
press(findByTestID(renderer.root, 'message-details-copy')[0]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(findByTestID(renderer.root, 'message-details-copy-feedback')[0]?.props.children).toBe(
'Copied to clipboard'
);

await act(async () => {
renderer.update(
sheetElement(storedMessage(assistantInfo({ id: 'msg-2' }), [textPart('second reply')]))
);
await Promise.resolve();
});
expect(findByTestID(renderer.root, 'message-details-copy-feedback')).toHaveLength(0);

await unmount(renderer);
});

it('puts the message text alone on the clipboard when a thinking block precedes it', async () => {
const message = storedMessage(assistantInfo(), [
{
Expand Down
Loading
Loading