diff --git a/apps/mobile/src/components/agents/part-renderer.test.ts b/apps/mobile/src/components/agents/part-renderer.test.ts index 8703cee587..837a9e00bb 100644 --- a/apps/mobile/src/components/agents/part-renderer.test.ts +++ b/apps/mobile/src/components/agents/part-renderer.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; -import { PartRenderer } from './part-renderer'; +import { PartRenderer, patchPartFileLabel } from './part-renderer'; import { ReasoningPartRenderer } from './reasoning-part-renderer'; import { TextPartRenderer } from './text-part-renderer'; import { PatchToolCardBody } from './tool-cards/patch-tool-card'; @@ -303,6 +303,14 @@ describe('PartRenderer', () => { }); describe('PartRenderer patch part summary', () => { + // `/workspace//sessions/` with the repo cloned at that + // root (services/cloud-agent-next/src/workspace.ts:202). + const WORKSPACE_ROOT = '/workspace/a7e4d40b-c28c-4df1-9a1e-f88e7eb467f1/sessions/W1s2'; + // `/workspace//worktrees/` is the root a worktree-backed + // session clones into instead (services/cloud-agent-next/src/workspace.ts:211). + const WORKTREE_ROOT = + '/workspace/a7e4d40b-c28c-4df1-9a1e-f88e7eb467f1/worktrees/worktree_9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'; + it('renders the file count and paths for a patch part', () => { const part = makePatchPart(['src/a.ts', 'src/b.ts']); // eslint-disable-next-line new-cap @@ -320,6 +328,78 @@ describe('PartRenderer patch part summary', () => { expect(findText(result, 'Updated 1 file')).toHaveLength(1); }); + it('shows the repo-relative file name for an absolute workspace path', () => { + const part = makePatchPart([`${WORKSPACE_ROOT}/README.md`]); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(result).not.toBeNull(); + expect(findText(result, 'README.md')).toHaveLength(1); + expect( + findAll( + result, + el => + el.type === 'Text' && + String((el.props as { children?: unknown }).children).includes('/workspace/') + ) + ).toHaveLength(0); + }); + + it('keeps the nested repo-relative path for an absolute workspace path', () => { + const part = makePatchPart([`${WORKSPACE_ROOT}/src/cli.test.ts`]); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(findText(result, 'src/cli.test.ts')).toHaveLength(1); + }); + + it('strips an org-scoped workspace prefix', () => { + const part = makePatchPart(['/workspace/org-1/user-1/sessions/W1s2/src/a.ts']); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(findText(result, 'src/a.ts')).toHaveLength(1); + }); + + it('strips a worktree workspace prefix', () => { + const part = makePatchPart([`${WORKTREE_ROOT}/README.md`]); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + expect(result).not.toBeNull(); + expect(findText(result, 'README.md')).toHaveLength(1); + expect( + findAll( + result, + el => + el.type === 'Text' && + String((el.props as { children?: unknown }).children).includes('/workspace/') + ) + ).toHaveLength(0); + }); + + it('strips an org-scoped worktree workspace prefix from a nested path', () => { + expect( + patchPartFileLabel('/workspace/org-1/user-1/worktrees/worktree_9b1deb4d/src/nested/a.ts') + ).toBe('src/nested/a.ts'); + }); + + it('renders each file row as a single middle-ellipsized line', () => { + const part = makePatchPart([`${WORKSPACE_ROOT}/src/a.ts`]); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part }); + const [fileRow] = findAll( + result, + el => el.type === 'Text' && (el.props as { numberOfLines?: number }).numberOfLines === 1 + ); + expect(fileRow).toBeDefined(); + expect(fileRow?.props).toMatchObject({ numberOfLines: 1, ellipsizeMode: 'middle' }); + }); + + it('leaves a path without the workspace prefix untouched', () => { + expect(patchPartFileLabel('src/a.ts')).toBe('src/a.ts'); + }); + + it('strips the absolute workspace prefix from a nested path', () => { + expect(patchPartFileLabel(`${WORKSPACE_ROOT}/src/nested/a.ts`)).toBe('src/nested/a.ts'); + }); + it('returns null for a patch part with no files', () => { const part = makePatchPart([]); // eslint-disable-next-line new-cap diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index 55eddd4717..e2f2c6e216 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -26,6 +26,23 @@ import { TextPartRenderer } from './text-part-renderer'; import { ToolPartRenderer } from './tool-part-renderer'; import { type OpenChildSession } from './child-session-section'; +/** + * The absolute cloud-agent workspace prefixes a patch path can carry: + * `/workspace//sessions//` for a session workspace and + * `/workspace//worktrees//` for a worktree-backed session + * (either optionally with an organization segment before the user id) — the + * repo is cloned at that root, so dropping it leaves the repo-relative path a + * reader wants instead of the workspace ids. + * + * services/cloud-agent-next/src/workspace.ts:202,211,371 + */ +const CLOUD_AGENT_WORKSPACE_PREFIX = + /^\/workspace\/(?:[^/]+\/)?[^/]+\/(?:sessions|worktrees)\/[^/]+\//; + +export function patchPartFileLabel(path: string): string { + return path.replace(CLOUD_AGENT_WORKSPACE_PREFIX, ''); +} + type PartRendererProps = { part: Part; isStreaming?: boolean; @@ -111,8 +128,13 @@ export function PartRenderer({ {summary} {part.files.map(file => ( - - {file} + + {patchPartFileLabel(file)} ))}