Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1458699
docs(mobile): generalize reviewer loop and switch orchestrator to opu…
iscekic Jul 17, 2026
77ad76e
docs(mobile): make Kilobot the only awaited reviewer explicit in the …
iscekic Jul 17, 2026
64f6bfd
docs(mobile): document Kilobot re-review triggers for crashed reviews
iscekic Jul 17, 2026
17d1f78
docs(mobile): delegation discipline — orchestrators steer loops befor…
iscekic Jul 17, 2026
3124c73
docs(mobile): make review and E2E repair loops explicit loop-until co…
iscekic Jul 17, 2026
e50ed1b
docs(mobile): scope the plan-gate round floor vs the escalation ladder
iscekic Jul 17, 2026
08a7ba2
docs(mobile): gate defect work on a pre-plan bug reproduction
iscekic Jul 18, 2026
be40f95
cloud: any-file attachment policy, storage contract, binary materiali…
iscekic Jul 20, 2026
472ef53
mobile: any-file attachment UX
iscekic Jul 20, 2026
58faf5d
cloud: attachment download presign + CLI capability plumbing
iscekic Jul 20, 2026
f8c0961
mobile+sdk: send attachments to remote CLI sessions
iscekic Jul 20, 2026
48f3ebc
mobile+sdk: fail-closed attachment capability on reconnect; retryable…
iscekic Jul 20, 2026
fb32f5a
mobile: format send-attachment test and drop unused exported type
iscekic Jul 20, 2026
92fe234
address review: consistent attachment extension, deny-list defense-in…
iscekic Jul 20, 2026
1bf8be0
Merge remote-tracking branch 'origin/main' into feat/any-file-attachm…
iscekic Jul 21, 2026
bb0e97a
test: split activeSessionSchema tests into a separate file
iscekic Jul 21, 2026
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
5 changes: 5 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const config: ExpoConfig = {
AdAttributionKit: {
PostbackCopyURL: 'https://appsflyer-skadnetwork.com/',
},
// Make the app's Documents directory user-visible in the iOS Files
// app so downloaded any-file attachments (uploaded via the cloud-agent
// composer) can be opened in place.
UIFileSharingEnabled: true,
Comment thread
iscekic marked this conversation as resolved.
LSSupportsOpeningDocumentsInPlace: true,
},
},
splash: {
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/app/(app)/agent-chat/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ export default function NewSessionScreen() {

const { addCandidates } = attachments;
const handleAddAttachment = useCallback(async () => {
addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
// Fire-and-forget: the upload hook owns its own progress + error toasts,
// and `canCreate` (computed from `attachments.isUploading` /
// `attachments.hasFailedAttachments`) gates the start-session button.
void addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
}, [addCandidates, showActionSheetWithOptions]);

// Cloud-Agent vs. remote-instance submit safety.
Expand Down
83 changes: 83 additions & 0 deletions apps/mobile/src/components/agents/attachment-chip-description.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { formatFileSize } from '@kilocode/kilo-chat';

export type ChipStateInput = {
filename: string;
size: number;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
progress: number | null;
terminal?: boolean;
};

type ChipDescription = {
/** Always present — the original filename. */
filename: string;
/** Human-readable file size (e.g. "1 KB"). Always present. */
sizeText: string;
/**
* Progress label for in-flight and uploaded states (e.g. "42%" or
* "Uploaded"). Empty string when no progress is surfaced.
*/
progressText: string;
/**
* Error message for failed states. `null` for non-error states — the
* chip renders the message in place of the size/progress secondary
* text and never both.
*/
message: string | null;
/** True ONLY for retryable error chips — drives the tap-to-retry affordance. */
showRetry: boolean;
/** True for every rendered chip — drives the X remove affordance. */
showRemove: boolean;
};

/**
* Human-readable progress label. `progress` is `null` when the upload task
* could not determine a total (server did not advertise Content-Length);
* we surface that honestly instead of fabricating a percentage.
*/
export function progressLabel(progress: number | null): string {
if (progress === null) {
return 'Uploading…';
}
if (progress >= 1) {
return 'Uploaded';
}
return `${Math.round(progress * 100)}%`;
}

/**
* Pure projection of the document-kind attachment chip. Kept separate
* from the React component so every feature state (happy, retryable,
* terminal, indeterminate progress) can be unit-tested without a
* renderer. The image-kind chip is a thumbnail overlay and shares no
* state-dependent text; the description below covers the document chip.
*/
export function describeAttachmentChip(state: ChipStateInput): ChipDescription {
const { filename, size, status, progress, terminal } = state;
const isErrored = status === 'error';
// Explicit `terminal === false` check: a chip is only retryable when
// the server-side flag is set to `false` (transient failure). An
// undefined `terminal` flag defaults to the conservative non-retryable
// bucket so a stray missing field cannot silently enable retry on a
// server-rejected file.
const isRetryable = isErrored && terminal === false;

let message: string | null = null;
if (isErrored) {
message = isRetryable ? 'Upload failed. Tap to retry.' : "This file can't be uploaded.";
}

let progressText = '';
if (status === 'pending' || status === 'uploading' || status === 'uploaded') {
progressText = progressLabel(progress);
}

return {
filename,
sizeText: formatFileSize(size),
progressText,
message,
showRetry: isRetryable,
showRemove: true,
};
}
98 changes: 95 additions & 3 deletions apps/mobile/src/components/agents/attachment-picker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ActionSheetProps } from '@expo/react-native-action-sheet';
import * as DocumentPicker from 'expo-document-picker';
import { describe, expect, it, vi } from 'vitest';

import { pickAgentAttachments } from './attachment-picker';
Expand All @@ -23,11 +24,34 @@ vi.mock('expo-image-picker', () => ({
requestCameraPermissionsAsync: vi.fn(),
}));

const getDocumentAsyncMock = vi.mocked(DocumentPicker.getDocumentAsync);

type ShowActionSheet = ActionSheetProps['showActionSheetWithOptions'];
type SheetButtonHandler = Parameters<ShowActionSheet>[1];

/**
* Drive `pickAgentAttachments` by capturing the sheet handler the
* production code registers, then invoking it with a button index.
* Avoids inline callback-shaped mocks that trip prefer-await-to-callbacks.
*/
async function pickWithSheetSelection(
buttonIndex: number
): Promise<Awaited<ReturnType<typeof pickAgentAttachments>>> {
const showActionSheet = vi.fn() as unknown as ShowActionSheet & {
mock: { calls: [unknown, SheetButtonHandler][] };
};
const resultPromise = pickAgentAttachments(showActionSheet);
const registered = showActionSheet.mock.calls[0]?.[1];
expect(registered).toEqual(expect.any(Function));
await Promise.resolve(registered?.(buttonIndex));
return resultPromise;
}

describe('agent attachment picker', () => {
it('opens a native action sheet that keeps all sources and the cancel action', () => {
const showActionSheet: ActionSheetProps['showActionSheetWithOptions'] = vi.fn(
(_options, _callback) => undefined
);
const showActionSheet = vi.fn() as unknown as ShowActionSheet & {
mock: { calls: unknown[][] };
};

void pickAgentAttachments(showActionSheet);

Expand All @@ -41,3 +65,71 @@ describe('agent attachment picker', () => {
expect(reactNativeMock.alert).not.toHaveBeenCalled();
});
});

describe('agent attachment picker (document MIME derivation)', () => {
it('derives MIME from the extension, never from the picker MIME', async () => {
getDocumentAsyncMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///cache/notes.md',
name: 'notes.md',
mimeType: 'application/octet-stream',
size: 12,
},
],
} as unknown as Awaited<ReturnType<typeof DocumentPicker.getDocumentAsync>>);

const candidates = await pickWithSheetSelection(2);
expect(candidates).toHaveLength(1);
expect(candidates[0]?.name).toBe('notes.md');
// The picker reported `application/octet-stream`; the picker must
// ignore it and return the extension-derived MIME.
expect(candidates[0]?.mimeType).toBe('text/plain');
});

it('returns application/octet-stream for an extension outside the canonical table', async () => {
getDocumentAsyncMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///cache/clip.mov',
name: 'clip.mov',
mimeType: 'video/quicktime',
size: 12,
},
],
} as unknown as Awaited<ReturnType<typeof DocumentPicker.getDocumentAsync>>);

const candidates = await pickWithSheetSelection(2);
expect(candidates[0]?.mimeType).toBe('application/octet-stream');
});

it('falls back to application/octet-stream for a filename with no usable extension', async () => {
getDocumentAsyncMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///cache/README',
name: 'README',
mimeType: 'text/plain',
size: 12,
},
],
} as unknown as Awaited<ReturnType<typeof DocumentPicker.getDocumentAsync>>);

const candidates = await pickWithSheetSelection(2);
expect(candidates[0]?.mimeType).toBe('application/octet-stream');
});

it('returns an empty list on cancel', async () => {
getDocumentAsyncMock.mockResolvedValueOnce({
canceled: true,
assets: [],
} as unknown as Awaited<ReturnType<typeof DocumentPicker.getDocumentAsync>>);

// Cancel is button index 3; Files (2) with a canceled document result
// also yields []. Use Files so the document path is exercised.
expect(await pickWithSheetSelection(2)).toEqual([]);
});
});
33 changes: 19 additions & 14 deletions apps/mobile/src/components/agents/attachment-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import * as ImagePicker from 'expo-image-picker';
import { type ActionSheetProps } from '@expo/react-native-action-sheet';
import { Alert, Linking } from 'react-native';

import {
AGENT_ATTACHMENT_MIME_BY_EXTENSION,
type AgentAttachmentExtension,
} from '@/lib/agent-attachments/constants';
import { mimeForExtension, normalizeAttachmentExtension } from '@/lib/agent-attachments/validate';
import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload';

const IMAGE_PICKER_OPTIONS = {
Expand All @@ -22,17 +19,18 @@ function showPermissionSettingsAlert({ message, title }: { message: string; titl
]);
}

function mimeTypeForExtension(extension: AgentAttachmentExtension): string {
return AGENT_ATTACHMENT_MIME_BY_EXTENSION[extension];
}

function normalizeImageAsset(asset: {
uri: string;
fileName?: string | null;
mimeType?: string | null;
fileSize?: number | null;
}): AgentAttachmentCandidate {
const name = asset.fileName ?? `image.${(asset.mimeType ?? 'image/png').split('/')[1] ?? 'png'}`;
// Image picker cannot return a filename with an arbitrary extension;
// synthesize one from the picker's MIME so `normalizeAttachmentExtension`
// can resolve a known key. The actual byte size is re-measured by the
// upload hook via `getInfoAsync`; `size` here is informational.
const fallbackName = `image.${(asset.mimeType ?? 'image/png').split('/')[1] ?? 'png'}`;
const name = asset.fileName ?? fallbackName;
return {
name,
uri: asset.uri,
Expand All @@ -47,14 +45,21 @@ function normalizeDocumentAsset(asset: {
mimeType?: string;
size?: number;
}): AgentAttachmentCandidate {
const dot = asset.name.lastIndexOf('.');
const ext =
dot > 0 ? (asset.name.slice(dot + 1).toLowerCase() as AgentAttachmentExtension) : null;
const mimeType = asset.mimeType ?? (ext ? mimeTypeForExtension(ext) : undefined);
// The picker MIME is intentionally NOT consulted. The cloud-agent
// storage layer rejects anything outside the canonical extension
// table, and iOS pickers report `application/octet-stream` for any
// extension the platform doesn't ship a UTI for. Resolving MIME from
// the extension makes the picker → upload hook contract exact.
const extension = normalizeAttachmentExtension(asset.name);
return {
name: asset.name,
uri: asset.uri,
mimeType,
// The candidate shape carries MIME for kilochat-picker parity, but
// the agent-attachments classifier ignores it and re-derives from
// the extension. No closed-union cast — the extension is whatever
// survives `normalizeAttachmentExtension`, including the `bin`
// fallback.
mimeType: mimeForExtension(extension),
size: asset.size,
};
}
Expand Down
Loading