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
36 changes: 36 additions & 0 deletions apps/mobile/src/lib/artifacts/artifact-crawl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,4 +487,40 @@ describe('buildSessionArtifacts', () => {
{ id: 's2', title: 'Named', updatedAt: UPDATED_AT, files: [] },
]);
});

it('hides the backend placeholder title behind the fallback label', () => {
const sessions = buildSessionArtifacts(
[
rowOf('s1', 'New session - 2026-09-22T01:09:45.623Z'),
rowOf('s2', 'Child session - 2026-09-22T01:09:45.623Z'),
],
new Map()
);

expect(sessions.map(session => session.title)).toEqual(['Session s1', 'Session s2']);
});

it('disambiguates artifacts that share a filename within a session', () => {
const artifacts = new Map([
[
's1',
[
{ id: 'file-1', mime: 'application/pdf', filename: 'report.pdf', size: 1 },
{ id: 'file-2', mime: 'application/pdf', filename: 'report.pdf', size: 2 },
],
],
]);

expect(buildSessionArtifacts([rowOf('s1', 'Named')], artifacts)).toEqual([
{
id: 's1',
title: 'Named',
updatedAt: UPDATED_AT,
files: [
{ id: 'file-1', name: 'report.pdf', mime: 'application/pdf', size: 1 },
{ id: 'file-2', name: 'report (2).pdf', mime: 'application/pdf', size: 2 },
],
},
]);
});
});
18 changes: 15 additions & 3 deletions apps/mobile/src/lib/artifacts/artifact-crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type ArtifactMirrorSession,
safeArtifactDisplayName,
safeArtifactSessionName,
uniqueArtifactDisplayNames,
} from '@/lib/artifacts/artifact-mirror-manifest';
import { sessionDisplayTitle } from '@/lib/session-display-title';
import { trpcClient } from '@/lib/trpc';

/**
Expand Down Expand Up @@ -334,17 +336,27 @@ export async function fetchSessionMessagesPage(
* Assemble the mirror's session entries. A session keeps its folder even with
* no files. Its label is sanitized ({@link safeArtifactSessionName}) rather than
* taken verbatim: the title is free text, and both file browsers need one
* non-empty path component, with `Session <id>` as the fallback.
* non-empty path component, with `Session <id>` as the fallback. The title is
* first gated through {@link sessionDisplayTitle}, so a row still carrying the
* backend's `New session - <ISO>` placeholder gets the fallback label instead
* of the machine string the app itself would never paint. Duplicate filenames
* within a session are disambiguated by {@link uniqueArtifactDisplayNames}, so
* the browser shows one row per artifact.
*/
export function buildSessionArtifacts(
sessions: MirrorSessionRow[],
artifactsBySession: ReadonlyMap<string, MaterializedArtifact[]>
): ArtifactMirrorSession[] {
return sessions.map(session => ({
id: session.id,
title: safeArtifactSessionName({ id: session.id, title: session.title }),
title: safeArtifactSessionName({
id: session.id,
title: sessionDisplayTitle(session.title) ?? null,
}),
updatedAt: session.updatedAt,
files: (artifactsBySession.get(session.id) ?? []).map(artifact => toMirrorFile(artifact)),
files: uniqueArtifactDisplayNames(
(artifactsBySession.get(session.id) ?? []).map(artifact => toMirrorFile(artifact))
),
}));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';

import {
safeArtifactDisplayName,
uniqueArtifactDisplayNames,
} from '@/lib/artifacts/artifact-mirror-manifest';
import { utf8ByteLength } from '@/lib/utf8-utils';

vi.mock('expo-file-system', () => ({
Directory: vi.fn(),
File: vi.fn(),
Paths: {},
}));

vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(),
shareAsync: vi.fn(),
}));

function fileOf(id: string, name: string, mime = 'application/pdf') {
return { id, name, mime, size: 1 };
}

describe('uniqueArtifactDisplayNames', () => {
it('leaves distinct names untouched', () => {
const files = [fileOf('f1', 'a.pdf'), fileOf('f2', 'b.pdf')];

expect(uniqueArtifactDisplayNames(files).map(file => file.name)).toEqual(['a.pdf', 'b.pdf']);
});

it('suffixes duplicates before the extension, keeping the first name', () => {
const files = [
fileOf('f1', 'report.pdf'),
fileOf('f2', 'report.pdf'),
fileOf('f3', 'report.pdf'),
];

expect(uniqueArtifactDisplayNames(files).map(file => file.name)).toEqual([
'report.pdf',
'report (2).pdf',
'report (3).pdf',
]);
});

it('suffixes a name with no extension', () => {
const files = [fileOf('f1', 'notes'), fileOf('f2', 'notes')];

expect(uniqueArtifactDisplayNames(files).map(file => file.name)).toEqual([
'notes',
'notes (2)',
]);
});

it('keeps every suffixed name inside the byte bound and its extension', () => {
const longBase = safeArtifactDisplayName({
id: 'f1',
name: `${'ä'.repeat(300)}.pdf`,
mime: 'application/pdf',
});
const files = [fileOf('f1', longBase), fileOf('f2', longBase)];

const names = uniqueArtifactDisplayNames(files).map(file => file.name);
expect(names[0]).not.toBe(names[1]);
for (const value of names) {
expect(utf8ByteLength(value)).toBeLessThanOrEqual(200);
expect(value.endsWith('.pdf')).toBe(true);
}
});

it('does not mutate the input files', () => {
const files = [fileOf('f1', 'report.pdf'), fileOf('f2', 'report.pdf')];

uniqueArtifactDisplayNames(files);

expect(files.map(file => file.name)).toEqual(['report.pdf', 'report.pdf']);
});
});
59 changes: 59 additions & 0 deletions apps/mobile/src/lib/artifacts/artifact-mirror-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,65 @@ export function safeArtifactSessionName({
return boundArtifactDisplayName(safeId.length > 0 ? `Session ${safeId}` : SESSION_NAME_FALLBACK);
}

/**
* Make every display name unique within one session.
*
* Two artifacts can share an agent-supplied filename, and the file browser
* paints `name` verbatim, so duplicates appear as indistinguishable rows in
* the session folder. The first occurrence keeps its name; a later duplicate
* gets a short ` (n)` suffix before the extension, with the stem truncated so
* the suffix and the extension both stay inside the byte bound. The input is
* never mutated.
*/
export function uniqueArtifactDisplayNames(files: ArtifactMirrorFile[]): ArtifactMirrorFile[] {
const used = new Set<string>();
return files.map(file => {
const name = uniqueArtifactDisplayName(file, used);
used.add(name);
return name === file.name ? file : { ...file, name };
});
}

/**
* A display name that is not in `used` yet. A collision takes a counter suffix
* before its extension; the suffix is guaranteed to survive truncation because
* the stem is bounded against the same byte budget, and the extension is capped
* so the counter never runs out of room. One candidate per taken name is tried,
* so a free one always exists.
*/
function uniqueArtifactDisplayName(file: ArtifactMirrorFile, used: ReadonlySet<string>): string {
if (!used.has(file.name)) {
return file.name;
}
const { stem, extension } = splitArtifactExtension(file.name);
const boundedExtension = truncateUtf8(extension, MAX_ARTIFACT_DISPLAY_NAME_BYTES / 2);
for (let index = 2; index <= used.size + 2; index += 1) {
const suffix = ` (${index})`;
const stemBudget =
MAX_ARTIFACT_DISPLAY_NAME_BYTES - utf8ByteLength(boundedExtension) - utf8ByteLength(suffix);
const candidate = `${truncateUtf8(stem, stemBudget)}${suffix}${boundedExtension}`;
if (!used.has(candidate)) {
return candidate;
}
}
return safeArtifactDisplayName({ id: file.id, name: '', mime: file.mime });
}

/** The stem and extension (with the dot) of a sanitized display name. */
type ArtifactDisplayNameParts = {
extension: string;
stem: string;
};

/** Split a sanitized display name into its stem and extension (with the dot). */
function splitArtifactExtension(name: string): ArtifactDisplayNameParts {
const extensionStart = name.lastIndexOf('.');
if (extensionStart > 0 && extensionStart < name.length - 1) {
return { extension: name.slice(extensionStart), stem: name.slice(0, extensionStart) };
}
return { extension: '', stem: name };
}

/**
* Drop whole sessions, oldest `updatedAt` first, until the summed file sizes
* fit `maxBytes`. The session entries stay — only their files leave — so an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,12 @@ describe('artifacts File Provider extension contract', () => {
// title (free text: nullable, unbounded, may carry a path separator) before
// it lands in the manifest, which is also the label Android shows.
expect(manifestWriterSource).toContain('export function safeArtifactSessionName');
expect(crawlSource).toContain(
'safeArtifactSessionName({ id: session.id, title: session.title })'
);
// The crawl gates the raw title through `sessionDisplayTitle` first, so a
// row still carrying the backend placeholder reaches the sanitizer as a
// missing title and falls back to `Session <id>` rather than the machine
// string the app never paints.
expect(crawlSource).toContain('title: safeArtifactSessionName({');
expect(crawlSource).toContain('title: sessionDisplayTitle(session.title) ?? null,');
});

it('returns the mirrored file through the app group container', () => {
Expand Down
Loading