Skip to content

Commit a115e6c

Browse files
committed
Replace hold-to-copy sheet with native text selection, and keep the turn summary across a resume
1 parent f09e0a0 commit a115e6c

16 files changed

Lines changed: 219 additions & 454 deletions

packages/app/App.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ import { pickFiles, pickPhotos, takePhoto } from "./src/ui/attachmentPicker";
6060
import { useDictation } from "./src/ui/useDictation";
6161
import { ApprovalSheet } from "./src/ui/ApprovalSheet";
6262
import { ThoughtSheet } from "./src/ui/ThoughtSheet";
63-
import { MessageSheet } from "./src/ui/MessageSheet";
6463
import { applyCommand, type SlashCommand } from "./src/slashCommands";
6564
import { CircleButton, Pill } from "./src/ui/controls";
6665
import { haptics } from "./src/ui/haptics";
@@ -521,8 +520,6 @@ function Pew2({ pairing, onUnpair }: { pairing: Pairing; onUnpair: () => void })
521520
// so the sheet lives outside the recycling list — a cell scrolled off screen
522521
// must not take the sheet down with it.
523522
const [thought, setThought] = useState<string | null>(null);
524-
/** The held message, open for copying and hand selection. */
525-
const [message, setMessage] = useState<string | null>(null);
526523
// The draft lives inside the dock, not here.
527524
//
528525
// Holding it at the root meant every keystroke re-rendered the entire app,
@@ -1111,17 +1108,6 @@ function Pew2({ pairing, onUnpair }: { pairing: Pairing; onUnpair: () => void })
11111108
}, []);
11121109
const closeThought = useCallback(() => setThought(null), []);
11131110

1114-
// Stable for the same reason as `openThought`. The pulse is the whole
1115-
// confirmation that a hold was recognised: the finger is still down and the
1116-
// sheet has not arrived yet, so without it a long press reads as a dead spot
1117-
// in the transcript.
1118-
const openMessage = useCallback((text: string) => {
1119-
haptics.tap();
1120-
Keyboard.dismiss();
1121-
setMessage(text);
1122-
}, []);
1123-
const closeMessage = useCallback(() => setMessage(null), []);
1124-
11251111
const answerPermission = useCallback(
11261112
(requestId: string, optionId: string, deny: boolean) => {
11271113
if (deny) haptics.warned();
@@ -1318,7 +1304,6 @@ function Pew2({ pairing, onUnpair }: { pairing: Pairing; onUnpair: () => void })
13181304
indicatorBottom={dockHeight}
13191305
onAtBottomChange={setAtBottom}
13201306
onOpenThought={openThought}
1321-
onCopyMessage={openMessage}
13221307
/>
13231308
) : !daemon.loadingSession ? (
13241309
// Cancels half the pane's lift, so the greeting settles in the middle
@@ -1483,7 +1468,6 @@ function Pew2({ pairing, onUnpair }: { pairing: Pairing; onUnpair: () => void })
14831468
<AttachmentSheet visible={attachOpen} onSelect={pickAttachment} onClose={closeAttach} />
14841469

14851470
<ThoughtSheet visible={thought !== null} text={thought ?? ""} onClose={closeThought} />
1486-
<MessageSheet visible={message !== null} text={message ?? ""} onClose={closeMessage} />
14871471

14881472
{/* One picker, pointed at whichever pill opened it. The mode selector is
14891473
excluded from the model menu so each pill owns exactly one list. */}

packages/app/src/agentHistory.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
agentSessionKey,
1010
isAgentSessionStub,
1111
needsResume,
12+
replaceAgentSessionStub,
1213
} from "./agentHistory";
1314
import type { Session } from "./useDaemon";
1415

@@ -178,3 +179,36 @@ test("nothing to add returns the same array, so no re-render is queued", () => {
178179
expect(mergeAgentSessions(before, "claude-code", [], true, NOW)).toBe(before);
179180
expect(mergeAgentSessions(before, undefined, undefined, true, NOW)).toBe(before);
180181
});
182+
183+
test("a resumed conversation keeps what the phone knew about it", () => {
184+
const stub: Session = {
185+
id: "agent:claude-code:s1",
186+
providerId: "claude-code",
187+
title: "Fix the build",
188+
startedAt: NOW,
189+
turns: [],
190+
configOptions: [],
191+
agentSessionId: "s1",
192+
cwd: "/repo",
193+
messageCount: 12,
194+
receipt: { verb: "Answered", duration: "5s", tools: 0, failed: 0 },
195+
};
196+
const live: Session = {
197+
id: "claude-code-live",
198+
providerId: "claude-code",
199+
title: "Fix the build",
200+
startedAt: NOW + 1,
201+
turns: [],
202+
configOptions: [],
203+
agentSessionId: "s1",
204+
};
205+
206+
const [resumed] = replaceAgentSessionStub([stub], live);
207+
208+
expect(resumed!.id).toBe("claude-code-live");
209+
expect(resumed!.cwd).toBe("/repo");
210+
expect(resumed!.messageCount).toBe(12);
211+
// The turn this device timed before the daemon forgot the session. Losing it
212+
// here is why "Answered in 5s" vanished on every reopen that resumes.
213+
expect(resumed!.receipt).toEqual({ verb: "Answered", duration: "5s", tools: 0, failed: 0 });
214+
});

packages/app/src/agentHistory.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ export function replaceAgentSessionStub(existing: Session[], live: Session): Ses
6868
...live,
6969
cwd: live.cwd ?? stub?.cwd,
7070
messageCount: live.messageCount ?? stub?.messageCount,
71+
// The last turn this device timed in this conversation. `session.started`
72+
// carries no such thing, so without this a resume swapped the row for one
73+
// that had never heard of it and "Answered in 5s" was gone for good — the
74+
// state every conversation is in once the daemon has been restarted.
75+
receipt: live.receipt ?? stub?.receipt,
7176
},
7277
...existing.filter((session) => session.agentSessionId !== live.agentSessionId),
7378
];

packages/app/src/turnReceipts.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from "bun:test";
22
import type { TurnReceipt } from "./activity";
3-
import { receiptOnOpen, recordReceipt } from "./turnReceipts";
3+
import { receiptOnOpen, receiptOnReplay, recordReceipt } from "./turnReceipts";
44
import type { Session } from "./useDaemon";
55

66
const answered: TurnReceipt = { verb: "Answered", duration: "3s", tools: 0, failed: 0 };
@@ -57,6 +57,31 @@ test("a conversation still working shows no summary, since none is measured yet"
5757
expect(running.receipt).toEqual(answered);
5858
});
5959

60+
test("a resumed conversation gets its summary back with its transcript", () => {
61+
// Resuming marks the session working on the way in, so the flag says nothing
62+
// about whether a turn is being timed; only `running` does.
63+
const sessions = recordReceipt([session({ busy: true })], "s1", answered);
64+
65+
expect(receiptOnReplay(sessions, "s1", false)).toEqual(answered);
66+
});
67+
68+
test("a replay that lands mid-turn names no finished turn", () => {
69+
const sessions = recordReceipt([session()], "s1", answered);
70+
71+
// The live activity line is what belongs under a running turn; a summary here
72+
// would date-stamp a reply that has not been given yet.
73+
expect(receiptOnReplay(sessions, "s1", true)).toBeUndefined();
74+
});
75+
76+
test("a replay never borrows the summary of another conversation", () => {
77+
const sessions = recordReceipt([session()], "s1", answered);
78+
79+
// The reason the old code cleared on replay: screen state belongs to the
80+
// thread being left. Reading from the session makes that impossible.
81+
expect(receiptOnReplay(sessions, "s2", false)).toBeUndefined();
82+
expect(receiptOnReplay(sessions, undefined, false)).toBeUndefined();
83+
});
84+
6085
test("a summary for a conversation the drawer does not hold changes nothing", () => {
6186
const before = [session()];
6287

packages/app/src/turnReceipts.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,28 @@ export function recordReceipt(
4646
export function receiptOnOpen(session: Pick<Session, "busy" | "receipt">): TurnReceipt | undefined {
4747
return session.busy === true ? undefined : session.receipt;
4848
}
49+
50+
/**
51+
* The summary to show once a resumed conversation's transcript lands.
52+
*
53+
* A resume rebuilds the thread from the agent's own copy, and that frame used to
54+
* clear the line unconditionally — correct when the summary was screen state
55+
* (it described the conversation being left), wrong now that it is stored on the
56+
* conversation. Reopening anything the daemon had forgotten therefore lost it
57+
* again, which is every conversation after a daemon restart.
58+
*
59+
* Read from the session rather than from what is on screen, for the reason the
60+
* old clear existed: the value on screen may belong to the previous thread.
61+
* `busy` is not consulted — the open marks a resuming conversation working on
62+
* the way in, and `running` is the honest question: is a turn being timed by
63+
* *this* device right now, in which case there is a live activity line to show
64+
* and no finished turn to name.
65+
*/
66+
export function receiptOnReplay(
67+
sessions: readonly Session[],
68+
sessionId: string | undefined,
69+
running: boolean,
70+
): TurnReceipt | undefined {
71+
if (running || !sessionId) return undefined;
72+
return sessions.find((session) => session.id === sessionId)?.receipt;
73+
}

packages/app/src/ui/ChatThread.harness.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ export default function ChatThreadHarness() {
5454
indicatorBottom={DOCK_HEIGHT}
5555
onAtBottomChange={setAtBottom}
5656
onOpenThought={() => {}}
57-
onCopyMessage={() => {}}
5857
/>
5958

6059
{/* Stand-in for the real dock: same job, obvious edge. Anything visible

packages/app/src/ui/ChatThread.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,6 @@ type Props = {
6060
onAtBottomChange: (atBottom: boolean) => void;
6161
/** Opens a thinking turn's full text. Must be stable: cells memo on it. */
6262
onOpenThought: (text: string) => void;
63-
/** Opens a held message for copying. Must be stable, for the same reason. */
64-
onCopyMessage: (text: string) => void;
6563
};
6664

6765
function ChatThreadView(
@@ -76,7 +74,6 @@ function ChatThreadView(
7674
indicatorBottom,
7775
onAtBottomChange,
7876
onOpenThought,
79-
onCopyMessage,
8077
}: Props,
8178
ref: React.Ref<ChatThreadRef>,
8279
) {
@@ -91,11 +88,11 @@ function ChatThreadView(
9188
// padding on the scroll content is not part of the list's layout math.
9289
return (
9390
<View style={index === 0 ? styles.firstRow : styles.row}>
94-
<Turn turn={item} onOpenThought={onOpenThought} onCopyMessage={onCopyMessage} />
91+
<Turn turn={item} onOpenThought={onOpenThought} />
9592
</View>
9693
);
9794
},
98-
[onOpenThought, onCopyMessage],
95+
[onOpenThought],
9996
);
10097

10198
// Mirrored into a ref so the inset effect below can read "is the reader at the

packages/app/src/ui/Dock.harness.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ function Screen() {
134134
indicatorBottom={dockHeight}
135135
onAtBottomChange={noop}
136136
onOpenThought={noop}
137-
onCopyMessage={noop}
138137
/>
139138
{/* What a wrapped line actually costs, where it can be read while typing.
140139
The root count is the one that matters: it should not move at all as

packages/app/src/ui/MarkdownText.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,17 +160,35 @@ const renderImage: RenderFunction = (node) => {
160160
};
161161

162162
const markdownRules: Partial<RenderRules> = {
163+
// Every inline run that is *not* a paragraph — a heading, a list item, a table
164+
// cell — bottoms out here, and this is the outermost Text of those blocks.
165+
//
166+
// That is why `selectable` is said twice, here and on the paragraph below.
167+
// Nested Text is virtual on both platforms: it is flattened into the one
168+
// native text view its top-level Text creates, and the native selection
169+
// gesture belongs to that view. So the flag only does anything on the
170+
// outermost Text of a block, and there are two kinds of those.
171+
textgroup: (node, children, _parents, styles) =>
172+
createElement(
173+
Text,
174+
{ ["key"]: node.key, selectable: true, style: styles.text as never },
175+
children,
176+
),
163177
// A paragraph must be one measured Text block. The library's default uses a
164178
// wrapping row of Text children; inside a list that row reports one-line
165179
// height while its text paints several lines, so following items overlap it.
166180
// One exception: a paragraph holding an image becomes a column, because
167181
// nesting a View in text layout collapses a percentage-width picture on iOS.
182+
// Only the Text branch is selectable — a View is not text and `selectable`
183+
// means nothing on it.
168184
paragraph: (node, children, _parents, styles) =>
169-
createElement(
170-
hasImageChild(node) ? View : Text,
171-
{ ["key"]: node.key, style: styles.paragraph as never },
172-
children,
173-
),
185+
hasImageChild(node)
186+
? createElement(View, { ["key"]: node.key, style: styles.paragraph as never }, children)
187+
: createElement(
188+
Text,
189+
{ ["key"]: node.key, selectable: true, style: styles.paragraph as never },
190+
children,
191+
),
174192
code_block: renderCodeBlock,
175193
fence: renderCodeBlock,
176194
image: renderImage,

0 commit comments

Comments
 (0)