Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
4feb5d7
Support markdown chat attachments
jakeleventhal Jul 12, 2026
f044e40
Avoid collapsing chat messages with long file links
jakeleventhal Jul 12, 2026
8d2b4db
Scope text attachments to their composer
jakeleventhal Jul 12, 2026
558841f
Serialize composer attachment batches
jakeleventhal Jul 12, 2026
9bd8b80
Clear stale text attachment errors
jakeleventhal Jul 12, 2026
3e668f6
Continue text attachments after image limit
jakeleventhal Jul 12, 2026
3d0f79a
Block send while attachments are pending
jakeleventhal Jul 12, 2026
0250de1
Handle pasted text file attachments
jakeleventhal Jul 12, 2026
ba5fdab
Store text attachments outside projects
jakeleventhal Jul 12, 2026
9b6dbd7
Report rejected files in mixed attachment batches
jakeleventhal Jul 12, 2026
42b7f3f
Render extensionless attachments as files
jakeleventhal Jul 12, 2026
e38c0b4
Count queued images from composer drafts
jakeleventhal Jul 12, 2026
29be486
Store text attachments in environment data
jakeleventhal Jul 12, 2026
86105fa
Synchronize attachment prompts before send
jakeleventhal Jul 12, 2026
bc71d7b
Recognize generated text attachment links
jakeleventhal Jul 12, 2026
1850608
Open generated HTML attachments in editors
jakeleventhal Jul 12, 2026
2b59f2d
Recognize legacy flat text attachments
jakeleventhal Jul 12, 2026
633c913
Use portable text attachment filenames
jakeleventhal Jul 12, 2026
c36a528
Clean up generated text attachments
jakeleventhal Jul 12, 2026
6ea5b18
Protect retained text attachments from deletion
jakeleventhal Jul 12, 2026
703f408
Discard unsent text attachments with drafts
jakeleventhal Jul 12, 2026
0269668
Discard attachments before removing environments
jakeleventhal Jul 12, 2026
831d861
Skip unused attachment retention scans
jakeleventhal Jul 12, 2026
761394f
Keep attachment cleanup out of projection replay
jakeleventhal Jul 12, 2026
d048f3c
Clean up removed draft text attachments
jakeleventhal Jul 12, 2026
0f1e5b8
Preserve shared draft text attachments
jakeleventhal Jul 12, 2026
773675d
Parse generated attachment links at end of prompt
jakeleventhal Jul 12, 2026
cf43261
Reconcile attachment cleanup after projection replay
jakeleventhal Jul 12, 2026
5dfdae1
Defer cleanup of removed text attachments
jakeleventhal Jul 12, 2026
1e7a2ed
Sweep orphaned attachments after projection replay
jakeleventhal Jul 12, 2026
0570f05
Clean up attachments after composer remounts
jakeleventhal Jul 12, 2026
5a7c007
Persist text attachment draft claims
jakeleventhal Jul 12, 2026
03c707d
Cover claimed drafts during projection cleanup
jakeleventhal Jul 12, 2026
11357b9
Mark deleted thread attachments after projection
jakeleventhal Jul 12, 2026
1fa960c
Delay attachment sweeps until projections bootstrap
jakeleventhal Jul 12, 2026
7848081
Use the Effect clock for attachment expiry
jakeleventhal Jul 12, 2026
6857273
Persist text attachment draft claims
jakeleventhal Jul 12, 2026
88f4645
Retain attachment claims until turn start
jakeleventhal Jul 12, 2026
c88fde5
Index pending text attachment expiry
jakeleventhal Jul 12, 2026
8644b99
Retry attachment claim reconciliation
jakeleventhal Jul 12, 2026
34aa041
Retain plan attachment claims until turn start
jakeleventhal Jul 12, 2026
3597166
Repair pending attachment markers on release retry
jakeleventhal Jul 12, 2026
1e45761
Persist attachment reconciliation through reconnects
jakeleventhal Jul 12, 2026
25747a3
Reconcile background attachment claims
jakeleventhal Jul 12, 2026
d1409ec
Fence destructive attachment claim releases
jakeleventhal Jul 12, 2026
8197a75
Abort environment removal when cleanup fails
jakeleventhal Jul 12, 2026
ee1b660
Treat detached attachment releases as idempotent
jakeleventhal Jul 12, 2026
628dbfb
Pause attachment claims during environment removal
jakeleventhal Jul 12, 2026
6533050
Reclaim attachment claims after removal abort
jakeleventhal Jul 12, 2026
51eb42b
Fence text attachment uploads during teardown
jakeleventhal Jul 12, 2026
3e14836
Fence newly discovered environment upload owners
jakeleventhal Jul 12, 2026
69c32e4
Resume owner upload fences after delete failure
jakeleventhal Jul 12, 2026
aa3c9cd
Tombstone destroyed attachment upload owners
jakeleventhal Jul 12, 2026
0cd53bf
Guard environment cleanup against all exits
jakeleventhal Jul 12, 2026
ca47555
Bound destructive attachment release retries
jakeleventhal Jul 12, 2026
c0021ce
Roll back text attachments when claiming fails
jakeleventhal Jul 12, 2026
dc00250
Clean up archived thread attachment owners
jakeleventhal Jul 12, 2026
0a3d757
Parse balanced Markdown attachment links
jakeleventhal Jul 12, 2026
f4843b5
Preserve due attachments when metadata is unreadable
jakeleventhal Jul 12, 2026
db4a3c5
Resume owned data after removal aborts
jakeleventhal Jul 12, 2026
97977a4
Reset expiry when attachments become retained
jakeleventhal Jul 12, 2026
bd1f742
Serialize text attachment claims and cleanup
jakeleventhal Jul 12, 2026
107c6e2
Preserve attachments with invalid metadata
jakeleventhal Jul 12, 2026
6d5f159
Parse only valid Markdown attachment links
jakeleventhal Jul 12, 2026
35dd2e4
Keep attachment releases retrying after teardown
jakeleventhal Jul 12, 2026
80f395d
Honor CommonMark code block boundaries
jakeleventhal Jul 12, 2026
2588e33
Write retained attachment metadata atomically
jakeleventhal Jul 12, 2026
e64343c
Persist pending attachment releases
jakeleventhal Jul 12, 2026
8292bdc
Respect paragraph context for indented Markdown
jakeleventhal Jul 12, 2026
dd419da
Restore attachment releases for all environments
jakeleventhal Jul 12, 2026
12d3a1b
Bound pending attachment release storage
jakeleventhal Jul 12, 2026
48ce3ec
Use CommonMark parsing for attachment links
jakeleventhal Jul 12, 2026
e545ecd
Coalesce attachment claim reconciliation
jakeleventhal Jul 12, 2026
ea6032a
Retry forced reconciliation after in-flight work
jakeleventhal Jul 12, 2026
47c23be
Reserve text attachment metadata filenames
jakeleventhal Jul 12, 2026
e5e272f
Include archived drafts in project removal
jakeleventhal Jul 12, 2026
d90a950
Confirm archived threads during project removal
jakeleventhal Jul 12, 2026
e846176
Refresh archived drafts before project deletion
jakeleventhal Jul 12, 2026
abf4dd8
Revalidate project threads after confirmation
jakeleventhal Jul 12, 2026
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
2 changes: 2 additions & 0 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ const providedCapabilitiesLayer = capabilitiesLayer.pipe(
const environmentOwnedDataCleanupLayer = Layer.succeed(
EnvironmentOwnedDataCleanup,
EnvironmentOwnedDataCleanup.of({
prepare: () => Effect.void,
resume: () => Effect.void,
clear: (environmentId) =>
Effect.all(
[
Expand Down
259 changes: 259 additions & 0 deletions apps/server/src/attachmentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

import {
collectTextAttachmentRelativePaths,
claimTextAttachment,
createAttachmentId,
createTextAttachmentPath,
parseThreadSegmentFromAttachmentId,
reconcileTextAttachments,
releaseTextAttachment,
resolveAttachmentPathById,
TEXT_ATTACHMENT_DELETE_GRACE_MS,
TEXT_ATTACHMENT_METADATA_FILE,
TEXT_ATTACHMENT_PENDING_DIRECTORY,
writeClaimedTextAttachment,
textAttachmentDirectory,
} from "./attachmentStore.ts";

describe("attachmentStore", () => {
Expand Down Expand Up @@ -77,4 +87,253 @@ describe("attachmentStore", () => {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("creates normalized text attachment paths under the environment attachment store", () => {
const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments");
const attachmentPath = createTextAttachmentPath({
attachmentsDir,
fileName: "../unsafe name.ts",
});

expect(NodePath.relative(attachmentsDir, attachmentPath)).toMatch(
/^text[/\\][0-9a-f-]+[/\\]\.\.-unsafe-name\.ts$/,
);
expect(attachmentPath).not.toContain(`${NodePath.sep}..${NodePath.sep}`);
});

it("removes a written attachment when its initial claim fails", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-write-claim-"),
);
try {
expect(() =>
writeClaimedTextAttachment(
{
attachmentsDir,
fileName: "context.md",
contents: "context",
draftOwnerId: "draft-owner",
},
() => false,
),
).toThrow(/initial text attachment claim/);
expect(NodeFS.readdirSync(NodePath.join(attachmentsDir, "text"))).toEqual([]);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("bounds text attachment basenames and avoids Windows reserved names", () => {
const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments");
const reservedPath = createTextAttachmentPath({ attachmentsDir, fileName: "CON.ts" });
const longPath = createTextAttachmentPath({
attachmentsDir,
fileName: `${"a".repeat(300)}.tsx`,
});

expect(NodePath.basename(reservedPath)).toBe("_CON.ts");
expect(NodePath.basename(longPath)).toHaveLength(120);
expect(NodePath.basename(longPath)).toMatch(/\.tsx$/);
});

it("reserves internal text attachment metadata basenames", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-internal-name-"),
);
try {
const generatedPath = createTextAttachmentPath({
attachmentsDir,
fileName: TEXT_ATTACHMENT_METADATA_FILE,
});
expect(NodePath.basename(generatedPath)).toBe("_t3-attachment.json");

const attachmentPath = writeClaimedTextAttachment({
attachmentsDir,
fileName: TEXT_ATTACHMENT_METADATA_FILE,
contents: "user attachment contents",
draftOwnerId: "internal-name-owner",
});
const metadataPath = NodePath.join(
NodePath.dirname(attachmentPath),
TEXT_ATTACHMENT_METADATA_FILE,
);
expect(NodePath.basename(attachmentPath)).toBe("_t3-attachment.json");
expect(attachmentPath).not.toBe(metadataPath);
expect(NodeFS.readFileSync(attachmentPath, "utf8")).toBe("user attachment contents");
expect(NodeFS.existsSync(metadataPath)).toBe(true);
expect(
releaseTextAttachment({
attachmentsDir,
path: attachmentPath,
draftOwnerId: "internal-name-owner",
nowMs: 0,
}),
).toBe(true);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("validates and collects server-owned text attachment paths", () => {
const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-(attachments)");
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" });
const encodedPath = encodeURI(attachmentPath).replaceAll("\\", "%5C");

expect(textAttachmentDirectory({ attachmentsDir, path: attachmentPath })).toBe(
NodePath.dirname(attachmentPath),
);
expect(
collectTextAttachmentRelativePaths({
attachmentsDir,
text: `before[notes.txt](${encodedPath}),after`,
}),
).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")]));
expect(
textAttachmentDirectory({ attachmentsDir, path: NodePath.join(attachmentsDir, "../nope") }),
).toBeNull();
});

it("ignores generated paths that are not Markdown links or are inside code", () => {
const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments");
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" });

expect(
collectTextAttachmentRelativePaths({
attachmentsDir,
text: [
`missing](${attachmentPath})`,
`\`[inline](${attachmentPath})\``,
"```md",
`[fenced](${attachmentPath})`,
"```",
].join("\n"),
}),
).toEqual(new Set());
expect(
collectTextAttachmentRelativePaths({
attachmentsDir,
text: `paragraph\n [continued](${attachmentPath})`,
}),
).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")]));
});

it("persists draft claims across reconciliation and restart-style reloads", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-claims-"),
);
try {
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" });
NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
NodeFS.writeFileSync(attachmentPath, "draft");
expect(
claimTextAttachment({
attachmentsDir,
path: attachmentPath,
draftOwnerId: "draft-owner",
}),
).toBe(true);

reconcileTextAttachments({
attachmentsDir,
retainedRelativePaths: new Set(),
nowMs: TEXT_ATTACHMENT_DELETE_GRACE_MS * 10,
});

expect(NodeFS.existsSync(attachmentPath)).toBe(true);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("cancels pending deletion when a copied draft reclaims an attachment", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-reclaim-"),
);
try {
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" });
NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
NodeFS.writeFileSync(attachmentPath, "draft");
claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "original" });
expect(
releaseTextAttachment({
attachmentsDir,
path: attachmentPath,
draftOwnerId: "original",
nowMs: 1_000,
}),
).toBe(true);
expect(
claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "copy" }),
).toBe(true);

reconcileTextAttachments({
attachmentsDir,
retainedRelativePaths: new Set(),
nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1,
});

expect(NodeFS.existsSync(attachmentPath)).toBe(true);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("deletes only expired unclaimed and unreferenced text attachments", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-expiry-"),
);
try {
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "orphan.md" });
NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
NodeFS.writeFileSync(attachmentPath, "orphan");

reconcileTextAttachments({
attachmentsDir,
retainedRelativePaths: new Set(),
nowMs: 1_000,
});
expect(NodeFS.existsSync(attachmentPath)).toBe(true);

reconcileTextAttachments({
attachmentsDir,
retainedRelativePaths: new Set(),
nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1,
});
expect(NodeFS.existsSync(attachmentPath)).toBe(false);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("preserves malformed metadata during full reconciliation", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-text-invalid-metadata-"),
);
try {
const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "keep.md" });
const directory = NodePath.dirname(attachmentPath);
const metadataPath = NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE);
const pendingPath = NodePath.join(
attachmentsDir,
"text",
TEXT_ATTACHMENT_PENDING_DIRECTORY,
`${NodePath.basename(directory)}.json`,
);
NodeFS.mkdirSync(directory, { recursive: true });
NodeFS.writeFileSync(attachmentPath, "keep");
NodeFS.writeFileSync(metadataPath, "malformed");

reconcileTextAttachments({
attachmentsDir,
retainedRelativePaths: new Set(),
nowMs: Number.MAX_SAFE_INTEGER,
});

expect(NodeFS.existsSync(attachmentPath)).toBe(true);
expect(NodeFS.readFileSync(metadataPath, "utf8")).toBe("malformed");
expect(NodeFS.existsSync(pendingPath)).toBe(false);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});
});
Loading
Loading