Compare commits

..
Author SHA1 Message Date
Mohamed Boudra ea7602b968 fix(app): copy complete assistant turns on iOS
Completed turns can span the rendered history and live-head segments. Preserve that boundary in the footer host so Copy turn includes assistant text from both segments.
2026-07-22 11:44:37 +00:00
8 changed files with 81 additions and 118 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
type StreamStrategy,
@@ -310,6 +311,34 @@ describe("layoutStream", () => {
expect(footerOwners(layout)).toEqual([assistant.id]);
});
it.each(["web", "android"] as const)(
"copies every assistant block when a completed turn spans history and live head on %s",
(platform) => {
const strategy = strategyFor(platform);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
assistantMessage("first block", 2, { groupId: "turn-1", index: 0 }),
assistantMessage("second block", 3, { groupId: "turn-1", index: 1 }),
],
head: [assistantMessage("final block", 4, { groupId: "turn-1", index: 2 })],
});
const footer = layout.auxiliaryTurnFooter;
expect(footer).not.toBeNull();
expect(
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items: footer!.items,
startIndex: footer!.startIndex,
precedingItems: footer!.precedingItems,
precedingStartIndex: footer!.precedingStartIndex,
}),
).toBe("first block\n\nsecond block\n\nfinal block");
},
);
it.each(["web", "android"] as const)(
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
+13
View File
@@ -10,6 +10,8 @@ export interface TurnFooterHost {
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
precedingItems: StreamItem[] | null;
precedingStartIndex: number | null;
}
export interface StreamLayoutItem {
@@ -66,12 +68,16 @@ function createTurnFooterHost(input: {
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
precedingItems: input.precedingItems ?? null,
precedingStartIndex: input.precedingStartIndex ?? null,
};
}
@@ -141,6 +147,11 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
precedingItems: footerItems === input.liveHead ? input.history : null,
precedingStartIndex:
footerItems === input.liveHead
? input.strategy.getHistoryLiveBoundaryIndex(input.history)
: null,
});
}
@@ -174,6 +185,8 @@ function resolveCompletedFooter(input: {
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
precedingItems: input.boundaryAboveItems,
precedingStartIndex: input.boundaryAboveIndex,
});
}
+18 -1
View File
@@ -272,8 +272,25 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): string {
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
const content = params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
if (!params.precedingItems || params.precedingStartIndex == null) {
return content;
}
const precedingContent = params.strategy.collectAssistantTurnContent(
params.precedingItems,
params.precedingStartIndex,
);
if (!precedingContent) {
return content;
}
if (!content) {
return precedingContent;
}
return `${precedingContent}\n\n${content}`;
}
export function isNearBottomForStreamRenderStrategy(
+15 -27
View File
@@ -3,8 +3,6 @@ import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
@@ -62,9 +60,7 @@ export const TurnFooter = memo(function TurnFooter({
return (
<CompletedTurnFooterRow
strategy={strategy}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
host={host}
supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn}
/>
@@ -73,16 +69,12 @@ export const TurnFooter = memo(function TurnFooter({
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
timing,
startIndex,
host,
supportsTimelineCursor,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
host: TurnFooterHost;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
@@ -90,9 +82,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
<TurnFooterRow>
<CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
host={host}
supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn}
/>
@@ -133,16 +123,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
function CompletedTurnFooter({
strategy,
items,
timing,
startIndex,
host,
supportsTimelineCursor,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
host: TurnFooterHost;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
@@ -150,14 +136,16 @@ function CompletedTurnFooter({
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
items: host.items,
startIndex: host.startIndex,
precedingItems: host.precedingItems,
precedingStartIndex: host.precedingStartIndex,
}),
[strategy, items, startIndex],
[strategy, host],
);
const boundary = resolveAssistantTurnForkBoundary({
items,
startIndex,
items: host.items,
startIndex: host.startIndex,
supportsTimelineCursor,
});
const handleFork = useCallback(
@@ -173,8 +161,8 @@ function CompletedTurnFooter({
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
completedAt={host.timing?.completedAt}
durationMs={host.timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
/>
</View>
+1 -3
View File
@@ -153,9 +153,7 @@ function renderStreamItemWithTurnFooter(input: {
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
host={footerHost}
supportsTimelineCursor={input.supportsTimelineCursor}
onForkAssistantTurn={input.onForkAssistantTurn}
/>
@@ -26,7 +26,6 @@ describe("existing run workspace resolution", () => {
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({
id: "workspace-2",
cwd: "/workspace/two",
createdForRun: false,
});
expect(fetchWorkspaces).toHaveBeenCalledWith({
filter: { query: "workspace-2" },
+5 -35
View File
@@ -504,32 +504,6 @@ async function connectToDaemonOrThrow(
interface RunWorkspace {
id?: string;
cwd: string;
createdForRun: boolean;
}
type CreateRunAgentInput = Parameters<ConnectedDaemonClient["createAgent"]>[0];
async function createAgentInRunWorkspace(
client: ConnectedDaemonClient,
workspace: RunWorkspace,
input: CreateRunAgentInput,
): Promise<AgentSnapshotPayload> {
try {
return await client.createAgent(input);
} catch (error) {
if (workspace.createdForRun && workspace.id) {
try {
const archived = await client.archiveWorkspace(workspace.id);
if (archived.error) {
console.error(`Warning: failed to clean up workspace ${workspace.id}: ${archived.error}`);
}
} catch (cleanupError) {
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
console.error(`Warning: failed to clean up workspace ${workspace.id}: ${message}`);
}
}
throw error;
}
}
export interface RunWorkspaceLookupClient {
@@ -549,7 +523,7 @@ export async function resolveExistingRunWorkspace(
});
const workspace = result.entries.find((entry) => entry.id === workspaceId);
if (workspace) {
return { id: workspace.id, cwd: workspace.workspaceDirectory, createdForRun: false };
return { id: workspace.id, cwd: workspace.workspaceDirectory };
}
throw {
@@ -577,7 +551,7 @@ async function resolveRunWorkspace(
}
if (!newWorkspace && resolveRunCallerAgentId()) {
return { cwd, createdForRun: false };
return { cwd };
}
const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim();
@@ -604,11 +578,7 @@ async function resolveRunWorkspace(
console.error(
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
);
return {
id: result.workspace.id,
cwd: result.workspace.workspaceDirectory ?? cwd,
createdForRun: true,
};
return { id: result.workspace.id, cwd: result.workspace.workspaceDirectory ?? cwd };
}
export async function runRunCommand(
@@ -657,7 +627,7 @@ export async function runRunCommand(
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await createAgentInRunWorkspace(client, workspace, {
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
@@ -728,7 +698,7 @@ export async function runRunCommand(
}
// Create the agent
const agent = await createAgentInRunWorkspace(client, workspace, {
const agent = await client.createAgent({
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
-51
View File
@@ -25,7 +25,6 @@ import { $ } from "zx";
import { mkdtemp, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
$.verbose = false;
@@ -269,56 +268,6 @@ try {
assert(output.includes("unknown option"), "should report unknown option for --isolation");
console.log("✓ run --isolation is rejected\n");
}
// Test 17: failed agent creation does not leave the run's workspace behind
{
console.log("Test 17: failed agent creation cleans up its workspace");
const ctx = await createE2ETestContext({ timeout: 45000 });
try {
await $({ cwd: ctx.workDir })`git init -b main`;
await $({ cwd: ctx.workDir })`git config user.email test@getpaseo.local`;
await $({ cwd: ctx.workDir })`git config user.name "Paseo Test"`;
await $({ cwd: ctx.workDir })`git commit --allow-empty -m initial`;
const before = await ctx.paseo(["workspace", "ls", "--json"]);
assert.strictEqual(before.exitCode, 0, before.stderr);
const result = await ctx.paseo(
[
"run",
"--provider",
"claude",
"--mode",
"bypass",
"--new-workspace",
"worktree",
"--worktree-mode",
"branch-off",
"--new-branch",
"failed-run-workspace",
"--base",
"main",
"--background",
"do something",
],
{
env: { PASEO_AGENT_ID: "", PASEO_WORKSPACE_ID: "" },
},
);
assert.notStrictEqual(result.exitCode, 0, "invalid mode should reject agent creation");
assert(
`${result.stdout}\n${result.stderr}`.includes("Invalid mode 'bypass'"),
`failure should reach provider mode validation\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
const after = await ctx.paseo(["workspace", "ls", "--json"]);
assert.strictEqual(after.exitCode, 0, after.stderr);
assert.deepStrictEqual(JSON.parse(after.stdout), JSON.parse(before.stdout));
} finally {
await ctx.stop();
}
console.log("✓ failed agent creation cleans up its workspace\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });