Skip to content
Open
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
102 changes: 70 additions & 32 deletions src/components/ChatInput/Attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import React, { useState, useRef } from 'react';

let _attachmentIdSeq = 0;
const nextAttachmentId = () => `${Date.now()}-${(++_attachmentIdSeq).toString(36)}`;
import { View, Text, Image, ScrollView, TouchableOpacity, Platform, ActionSheetIOS } from 'react-native';
import { View, Text, Image, ScrollView, TouchableOpacity, Platform, ActionSheetIOS, ActivityIndicator } from 'react-native';
import { launchImageLibrary, launchCamera, Asset } from 'react-native-image-picker';
import { pick, types, isErrorWithCode, errorCodes } from '@react-native-documents/picker';
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
import { MediaAttachment } from '../../types';
import { documentService } from '../../services/documentService';
import { takePendingChatAttachments } from '../../services/chatAttachmentInbox';
import { AlertState, showAlert, hideAlert } from '../CustomAlert';
import { createStyles } from './styles';
import { isPickerStuck } from '../../utils/pickerErrorUtils';

// ─── useAttachments hook ──────────────────────────────────────────────────────

export function useAttachments(setAlertState: (state: AlertState) => void) {
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
// Seed from the inbox (e.g. a transcript handed off by the Pro recorder's
// "Attach to chat"), consumed once on mount.
const [attachments, setAttachments] = useState<MediaAttachment[]>(() => takePendingChatAttachments());
const isPickingRef = useRef(false);

const addAttachments = (assets: Asset[]) => {
Expand Down Expand Up @@ -133,9 +136,13 @@ export function useAttachments(setAlertState: (state: AlertState) => void) {
interface AttachmentPreviewProps {
attachments: MediaAttachment[];
onRemove: (id: string) => void;
// Summarize a document/transcript attachment that may be too large for the
// context window. Optional so other ChatInput consumers can omit it.
onSummarize?: (attachment: MediaAttachment) => void;
summarizingId?: string | null;
}

export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({ attachments, onRemove }) => {
export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({ attachments, onRemove, onSummarize, summarizingId }) => {
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);

Expand All @@ -149,36 +156,67 @@ export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({ attachment
contentContainerStyle={styles.attachmentsContent}
showsHorizontalScrollIndicator={false}
>
{attachments.map(attachment => (
<View key={attachment.id} testID={`attachment-preview-${attachment.id}`} style={styles.attachmentPreview}>
{attachment.type === 'image' ? (
<Image
testID={`attachment-image-${attachment.id}`}
source={{ uri: attachment.uri }}
style={styles.attachmentImage}
/>
) : attachment.type === 'audio' ? (
<View testID={`audio-preview-${attachment.id}`} style={styles.documentPreview}>
<Icon name="mic" size={24} color={colors.primary} />
<Text style={styles.documentName} numberOfLines={2}>Voice</Text>
</View>
) : (
<View testID={`document-preview-${attachment.id}`} style={styles.documentPreview}>
<Icon name="file-text" size={24} color={colors.primary} />
<Text style={styles.documentName} numberOfLines={2}>
{attachment.fileName || 'Document'}
</Text>
</View>
)}
<TouchableOpacity
testID={`remove-attachment-${attachment.id}`}
style={styles.removeAttachment}
onPress={() => onRemove(attachment.id)}
{attachments.map(attachment => {
const canSummarize = !!onSummarize && !!attachment.textContent && attachment.type !== 'image';
const isBusy = summarizingId === attachment.id;
return (
<View
key={attachment.id}
testID={`attachment-preview-${attachment.id}`}
style={[styles.attachmentPreview, canSummarize && styles.attachmentPreviewDoc]}
>
<Text style={styles.removeAttachmentText}>&times;</Text>
</TouchableOpacity>
</View>
))}
{attachment.type === 'image' ? (
<Image
testID={`attachment-image-${attachment.id}`}
source={{ uri: attachment.uri }}
style={styles.attachmentImage}
/>
) : attachment.type === 'audio' ? (
<View testID={`audio-preview-${attachment.id}`} style={styles.documentPreview}>
<Icon name="mic" size={24} color={colors.primary} />
<Text style={styles.documentName} numberOfLines={2}>Voice</Text>
</View>
) : (
<View
testID={`document-preview-${attachment.id}`}
style={[styles.documentPreview, canSummarize && styles.documentPreviewDoc]}
>
<View style={styles.documentNameRow}>
<Icon name="file-text" size={18} color={colors.primary} />
<Text style={styles.documentName} numberOfLines={1}>
{attachment.fileName || 'Document'}
</Text>
</View>
{canSummarize ? (
isBusy ? (
<View style={styles.summarizeBusy}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.summarizeBusyText}>Summarizing</Text>
</View>
) : (
<TouchableOpacity
testID={`summarize-attachment-${attachment.id}`}
style={styles.summarizeButton}
onPress={() => onSummarize!(attachment)}
activeOpacity={0.8}
>
<Icon name="zap" size={11} color={colors.background} />
<Text style={styles.summarizeButtonText}>Summarize</Text>
</TouchableOpacity>
)
) : null}
</View>
)}
<TouchableOpacity
testID={`remove-attachment-${attachment.id}`}
style={styles.removeAttachment}
onPress={() => onRemove(attachment.id)}
>
<Text style={styles.removeAttachmentText}>&times;</Text>
</TouchableOpacity>
</View>
);
})}
</ScrollView>
);
};
13 changes: 12 additions & 1 deletion src/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { createStyles, PILL_ICON_SIZE, ANIM_DURATION_IN, ANIM_DURATION_OUT } from './styles';
import { QueueRow } from './Toolbar';
import { AttachmentPreview, useAttachments } from './Attachments';
import { useSummarizeAttachment } from './useSummarizeAttachment';
import { useVoiceInput } from './Voice';
import { QuickSettingsPopover, AttachPickerPopover } from './Popovers';
import { useKeyboardAwarePopover } from './useKeyboardAwarePopover';
Expand Down Expand Up @@ -56,7 +57,7 @@

// ─── Main Component ─────────────────────────────────────────────────────────

export const ChatInput: React.FC<ChatInputProps> = ({

Check failure on line 60 in src/components/ChatInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint

Arrow function has too many lines (358). Maximum allowed is 350
onSend,
onStop,
disabled,
Expand Down Expand Up @@ -103,6 +104,11 @@

const { attachments, removeAttachment, clearAttachments, handlePickImage, handlePickDocument, addAudioAttachment } = useAttachments(setAlertState);
attachmentsRef.current = attachments;
const { summarizingId, handleSummarize } = useSummarizeAttachment();
const onSummarizeAttachment = async (attachment: MediaAttachment) => {
await handleSummarize(attachment);
removeAttachment(attachment.id);
Comment on lines +107 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Only remove the attachment after a successful summary.

handleSummarize() catches its own failures and also returns early on some no-op paths, so this unconditional removeAttachment() drops the original attachment even when summarization failed or never started.

Suggested fix
   const { summarizingId, handleSummarize } = useSummarizeAttachment();
   const onSummarizeAttachment = async (attachment: MediaAttachment) => {
-    await handleSummarize(attachment);
-    removeAttachment(attachment.id);
+    const summarized = await handleSummarize(attachment);
+    if (summarized) removeAttachment(attachment.id);
   };

handleSummarize() should return a success flag (or rethrow on failure) so the caller can make this decision correctly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChatInput/index.tsx` around lines 107 - 110, The attachment
removal in onSummarizeAttachment is unconditional, so update the summarize flow
to only call removeAttachment after a confirmed successful handleSummarize()
result. Adjust useSummarizeAttachment/handleSummarize to return an explicit
success flag or rethrow on failure, then have ChatInput decide removal based on
that outcome so failed or skipped summaries do not drop the original attachment.

};
Comment on lines +107 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Attachment removed on failure 🐞 Bug ≡ Correctness

ChatInput always calls removeAttachment() after awaiting handleSummarize(), but handleSummarize()
can return early (busy/no text/no model) and also swallows errors, so attachments can be discarded
even when no summary was produced. This causes silent data loss and prevents retrying summarization.
Agent Prompt
## Issue description
`ChatInput` removes an attachment unconditionally after `handleSummarize()`, but `handleSummarize()` can no-op (early return) or fail (caught internally). This can delete the user’s attachment without producing a summary.

## Issue Context
- `onSummarizeAttachment` always calls `removeAttachment(attachment.id)` after `await handleSummarize(attachment)`.
- `handleSummarize` returns early when already summarizing, when `textContent` is empty, or when there is no model/conversation; it also catches errors and does not rethrow.

## Fix Focus Areas
- src/components/ChatInput/index.tsx[107-111]
- src/components/ChatInput/useSummarizeAttachment.ts[29-41]
- src/components/ChatInput/useSummarizeAttachment.ts[78-120]

## Suggested fix
- Change `handleSummarize` to return a status (e.g., `Promise<boolean>`), where `true` means a summary was successfully generated and posted.
  - Return `false` on early exits (busy/no text/no model).
  - Either rethrow on failure or return `false` on failure.
- In `onSummarizeAttachment`, only remove the attachment when `handleSummarize` indicates success.
- Optionally surface an alert/toast when summarization can’t run (e.g., no model loaded).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const interfaceMode = useUiModeStore((s) => s.interfaceMode);
const isAudioMode = interfaceMode === 'audio';

Expand Down Expand Up @@ -306,7 +312,12 @@

return (
<View style={styles.container}>
<AttachmentPreview attachments={attachments} onRemove={removeAttachment} />
<AttachmentPreview
attachments={attachments}
onRemove={removeAttachment}
onSummarize={onSummarizeAttachment}
summarizingId={summarizingId}
/>
<QueueRow
queueCount={queueCount}
queuedTexts={queuedTexts}
Expand Down
45 changes: 45 additions & 0 deletions src/components/ChatInput/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
borderRadius: 8,
overflow: 'hidden' as const,
},
// Wider, taller chip for document/transcript attachments so the file name and
// the Summarize action are both fully visible (the square image size clipped
// the button).
attachmentPreviewDoc: {
width: 168,
height: 76,
},
attachmentImage: {
width: '100%' as const,
height: '100%' as const,
Expand All @@ -42,13 +49,51 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
alignItems: 'center' as const,
padding: 4,
},
documentPreviewDoc: {
justifyContent: 'space-between' as const,
alignItems: 'stretch' as const,
padding: 8,
paddingRight: 22,
},
documentNameRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
gap: 6,
},
documentName: {
fontSize: 10,
fontFamily: FONTS.mono,
color: colors.textMuted,
textAlign: 'center' as const,
marginTop: 4,
},
Comment on lines +58 to 69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Split the inline filename style from the stacked one.

documentName still carries marginTop: 4 from the old centered layout, but it now also renders inside documentNameRow. That offsets the filename downward relative to the file icon. A separate inline text style, or moving the top margin to the audio-only variant, will avoid the misalignment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChatInput/styles.ts` around lines 58 - 69, The shared document
filename style in documentName still includes spacing from the old stacked
layout, which now misaligns the text when rendered inside documentNameRow. Split
the style into separate variants in styles.ts: keep the centered/stacked spacing
only for the audio-only or vertical layout, and remove the top margin from the
inline filename style used with the file icon. Update the relevant consumer(s)
to use the appropriate style variant so documentNameRow and the filename stay
vertically aligned.

summarizeButton: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
gap: 4,
paddingHorizontal: SPACING.sm,
paddingVertical: 5,
borderRadius: 8,
backgroundColor: colors.primary,
},
summarizeButtonText: {
fontSize: 11,
fontFamily: FONTS.mono,
color: colors.background,
},
summarizeBusy: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
gap: 6,
paddingVertical: 4,
},
summarizeBusyText: {
fontSize: 11,
fontFamily: FONTS.mono,
color: colors.primary,
},
removeAttachment: {
position: 'absolute' as const,
top: 2,
Expand Down
124 changes: 124 additions & 0 deletions src/components/ChatInput/useSummarizeAttachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { MediaAttachment } from '../../types';
import { transcriptSummarizer } from '../../services';
import { useChatStore, useAppStore } from '../../stores';
import logger from '../../utils/logger';

/** Throttle for streaming the summary into the message (~20 paints/sec). */
const STREAM_FLUSH_MS = 50;

/** mm:ss for a millisecond offset, used to label an attached transcript range. */
function fmtClock(ms: number): string {
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}

/**
* Summarize an attached document/transcript that is too large to fit the model's
* context window. Posts a user message ("Summarize <file>") and an assistant
* message, then streams progress into that assistant message (part i of N,
* combining) before replacing it with the final summary. Self-contained: reads
* the active conversation + model from the global stores, so it does not need
* props threaded down from the chat screen.
*/
export function useSummarizeAttachment() {
const [summarizingId, setSummarizingId] = useState<string | null>(null);

const handleSummarize = async (attachment: MediaAttachment): Promise<void> => {
if (summarizingId) return;
const text = attachment.textContent?.trim();
if (!text) return;

const chat = useChatStore.getState();
let conversationId = chat.activeConversationId;
if (!conversationId) {
const modelId = useAppStore.getState().activeModelId;
if (!modelId) return; // no model loaded - nothing to summarize with
conversationId = chat.createConversation(modelId);
chat.setActiveConversation(conversationId);
}

const label = attachment.fileName || 'transcript';
const range =
attachment.transcriptStartMs != null && attachment.transcriptEndMs != null
? ` (${fmtClock(attachment.transcriptStartMs)} to ${fmtClock(attachment.transcriptEndMs)})`
: '';
chat.addMessage(conversationId, { role: 'user', content: `Summarize ${label}${range}` });
const placeholder = chat.addMessage(conversationId, { role: 'assistant', content: 'Starting...' });

setSummarizingId(attachment.id);
// Stream the work in place. The map phase streams each part as it is written
// (so a multi-chunk run shows text from part 1, not a static counter for
// minutes), then the final combine pass restreams the answer over the top.
// updateMessageContent rebuilds the conversations tree on every call, so we
// flush on a ~50ms timer (matching the main generation loop) rather than per
// token, otherwise the JS thread saturates and the UI only paints at the end.
let uiPhase: 'map' | 'final' = 'map';
let total = 0;
let current = 0;
const doneParts: string[] = [];
let curPart = '';
let finalText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;

const compose = (): string => {
if (uiPhase === 'final') return finalText || 'Combining the parts...';
const parts = [...doneParts, curPart].filter((s) => s.trim());
const header = total > 1 ? `Summarizing part ${current} of ${total}\n\n` : 'Summarizing...\n\n';
return parts.length ? header + parts.join('\n\n') : header.trim();
};
const flush = () => {
flushTimer = null;
useChatStore.getState().updateMessageContent(conversationId!, placeholder.id, compose());
};
const scheduleFlush = () => { if (!flushTimer) flushTimer = setTimeout(flush, STREAM_FLUSH_MS); };

try {
const summary = await transcriptSummarizer.summarize(text, {
onProgress: (p) => {
if (p.phase === 'chunking') {
total = p.total;
} else if (p.phase === 'mapping') {
if (p.total <= 1) {
uiPhase = 'final'; // single pass: the streamed text is the answer
} else {
if (curPart.trim()) doneParts.push(curPart.trim());
curPart = '';
total = p.total;
current = p.current;
}
} else if (p.phase === 'combining') {
if (curPart.trim()) doneParts.push(curPart.trim());
curPart = '';
uiPhase = 'final';
finalText = '';
}
scheduleFlush();
},
onToken: (delta) => {
if (uiPhase === 'final') finalText += delta;
else curPart += delta;
scheduleFlush();
},
});
if (flushTimer) clearTimeout(flushTimer);
// Final trimmed summary (streamed text may have leading/trailing space).
useChatStore.getState().updateMessageContent(conversationId, placeholder.id, summary);
} catch (e) {
if (flushTimer) clearTimeout(flushTimer);
const msg = e instanceof Error ? e.message : 'Summarization failed';
useChatStore.getState().updateMessageContent(
conversationId,
placeholder.id,
`Could not summarize this transcript.\n\n${msg}`,
);
logger.warn('[useSummarizeAttachment] failed:', e);
} finally {
setSummarizingId(null);
}
};

return { summarizingId, handleSummarize };
}
27 changes: 27 additions & 0 deletions src/services/chatAttachmentInbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Chat Attachment Inbox
*
* A one-shot hand-off for seeding the chat composer with an attachment created
* elsewhere (e.g. the Pro recorder's "Attach to chat", which builds a transcript
* document and navigates to the Chat screen). The composer consumes the pending
* attachments once on mount, then clears them.
*
* Kept as a tiny module-level store (not a route param) so a large transcript
* body never has to be serialized through navigation, and so Pro can hand off to
* core without core importing anything from Pro.
*/
import { MediaAttachment } from '../types';

let pending: MediaAttachment[] = [];

/** Queue attachments to seed the next chat composer mount. Replaces any pending. */
export function setPendingChatAttachments(attachments: MediaAttachment[]): void {
pending = attachments;
}

/** Return and clear the pending attachments (empty array if none). */
export function takePendingChatAttachments(): MediaAttachment[] {
const taken = pending;
pending = [];
return taken;
}
Loading
Loading