Skip to content

Commit 1f286fe

Browse files
committed
fix(web): keep a sent message as mail delivers it
Mail delivery drops spaces and tabs at the end of each line, so a message with them never matched the conversation's copy and showed twice. One shared rule gives the text mail delivers, and the page sends and keeps that text for a message, words with files and the opening idea. A hub test runs the text through Interchange's mail encoding and decoding, with and without a file, so a change in how mail treats it fails there.
1 parent f94b33f commit 1f286fe

4 files changed

Lines changed: 60 additions & 5 deletions

File tree

‎apps/hub/src/mail/conversation-turns.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { $ } from "bun";
6+
import {
7+
assembleSignedContent,
8+
decodePartBytes,
9+
extractPartByPath,
10+
parseMimePart,
11+
} from "@intx/mime";
12+
import { deliveredText } from "@corbits-builder/schemas/conversation";
613
import { resumeMessage } from "../config/resume.ts";
714
import { specialistEndedMessage } from "../config/specialist-ended.ts";
815
import { readDurableConversationTurns } from "./conversation-turns.ts";
@@ -98,3 +105,36 @@ test("a resumed message reads back without its held context", async () => {
98105
const dir = await runRepo([resumeMessage(context, "Line one\n\nLine two")]);
99106
expect(await personTexts(dir)).toEqual(["Line one\n\nLine two"]);
100107
});
108+
109+
// The page keeps `deliveredText`; mail must carry it unchanged for the page to recognise its copy.
110+
const delivered = (text: string, withFile: boolean) => {
111+
const file = {
112+
name: "notes.txt",
113+
contentType: "text/plain",
114+
data: new TextEncoder().encode("notes"),
115+
};
116+
const signed = assembleSignedContent({
117+
kind: "conversation",
118+
text,
119+
...(withFile ? { attachments: [file] } : {}),
120+
});
121+
const part = parseMimePart(extractPartByPath(signed, "1"));
122+
return new TextDecoder()
123+
.decode(decodePartBytes(part.body, part.headers))
124+
.replace(/\r\n?/g, "\n");
125+
};
126+
127+
test("the text a person sends survives mail delivery unchanged", () => {
128+
const typed = [
129+
"First line \nsecond line\t\n\nthird ",
130+
"On the brief:\n\n> keep it short ",
131+
"café \nnaïve",
132+
`${"a".repeat(80)} \nb`,
133+
"tide times \u2028with alerts",
134+
"old \rline \r\nbreaks",
135+
];
136+
for (const text of typed.map(deliveredText)) {
137+
expect(delivered(text, false)).toBe(text);
138+
expect(delivered(text, true)).toBe(text);
139+
}
140+
});

‎apps/web/src/pages/projects.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { COMPOSER_BOX_CLASS } from "./workspace/pane-classes.ts";
3030
import { HOME_COMPOSER_PLACEHOLDER, canStartProject } from "./home-view.ts";
3131
import "./home-layout.css";
3232
import type { ProjectImport } from "@corbits-builder/schemas/project-import";
33+
import { deliveredText } from "@corbits-builder/schemas/conversation";
3334

3435
export function Projects({
3536
onOpen,
@@ -56,11 +57,11 @@ export function Projects({
5657
}
5758
setBusy("start");
5859
try {
60+
const opening = deliveredText(problem);
5961
const created = await createProject({
60-
problemStatement: problem.trim(),
62+
problemStatement: opening,
6163
...(profile === undefined ? {} : { profile }),
6264
});
63-
const opening = problem.trim();
6465
setProblem("");
6566
onChanged();
6667
// The hub mailed the opening sentence to the coordinator; the workspace

‎apps/web/src/pages/workspace/index.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { ProductApiError, type ProjectArtifact } from "@corbits-builder/client";
5555
import type { InFlightRun } from "@corbits-builder/schemas/artifact";
5656
import { toast } from "sonner";
5757
import { sendMessage, type ConversationTurn } from "../../api/conversation.ts";
58+
import { deliveredText } from "@corbits-builder/schemas/conversation";
5859
import { subscribeWorkflowRun } from "../../api/run-stream.ts";
5960
import { workspaceTenantId } from "../../api/auth.ts";
6061
import {
@@ -169,7 +170,8 @@ function openingDraft(
169170
projectId: string,
170171
openingMessage: string | undefined,
171172
): ChatMessage[] {
172-
const text = openingMessage?.trim() ?? "";
173+
if (openingMessage === undefined) return [];
174+
const text = deliveredText(openingMessage);
173175
if (text.length === 0) return [];
174176
return [
175177
{
@@ -669,7 +671,7 @@ export function StageWorkspace({
669671
body: string,
670672
clearComposer = false,
671673
): Promise<boolean> => {
672-
const text = body.trim();
674+
const text = deliveredText(body);
673675
if (text.length === 0 || sending) return false;
674676
setSending(true);
675677
setSendFailed(false);
@@ -736,7 +738,7 @@ export function StageWorkspace({
736738

737739
const deliverFiles = async (): Promise<boolean> => {
738740
if (sending) return false;
739-
const text = value.trim();
741+
const text = deliveredText(value);
740742
const attached = files;
741743
setSending(true);
742744
setSendFailed(false);

‎packages/schemas/src/conversation.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ export type ConversationMessage = Readonly<{
88
Readonly<{ mimeType: string; data: string; name?: string }>
99
>;
1010
}>;
11+
12+
/**
13+
* A person's words as mail delivers them and the conversation reads them back: LF line breaks, no
14+
* spaces or tabs at the end of a line, trimmed. The page sends and keeps this text, so it recognises
15+
* the conversation's copy.
16+
*/
17+
export const deliveredText = (text: string) =>
18+
text
19+
.replace(/\r\n?/g, "\n")
20+
.replace(/[ \t]+(?=\n|$)/g, "")
21+
.trim();
22+
1123
export const ConversationReceipt = type({
1224
runId: "string",
1325
address: "string",

0 commit comments

Comments
 (0)