Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,11 @@ The [Effect probes](../../../../specs/models/session-files.effect-probe.mjs) use
Reproduce with a temporary effect@3.18.4 installation (`npm install --prefix <temp> --ignore-scripts --no-audit --no-fund effect@3.18.4`). Copy specs/models/session-files.effect-probe.mjs and packages/components/src/providers/store-ref-tracker.ts retaining their repository-relative paths; run `node --experimental-strip-types --test <temp>/specs/models/session-files.effect-probe.mjs`. This establishes only those boundaries, not real XHR/IPC, writer durability, cross-window coordination, or E01–E12 acceptance. Official v3 sources and current code references are in the Spec.

The original design checkout had 20 broken links to uninitialized ACP submodules. The independent implementation checkout initializes the pinned submodules: document checks now have zero errors and no registered SHA-protected topics. The three action/composer suites pass 69 tests for layer 1; full repository verification and PR references are recorded with the stack status. Product draft behavior and device acceptance remain incomplete. Specs remain draft and this Note remains proposed.

## Layer 2 implementation

Layer 2 creates one workspace Effect resource owner for file preparation, image uploads, and send-path store borrows. React keeps Promise interfaces. Navigation does not cancel uploads; workspace disposal cancels and joins work before closing caches/transports. Noncancelable IPC must settle before dependency release. New and continuing conversations share file preparation; cancellation cannot trigger fallback upload, and multipart cleanup is awaited.

Deterministic tests cover parallel cancellation, late store acquisition, sibling isolation, actual XHR cancellation, and progress versus successful response. Transfer still starts on addition. Persistent submission and complete draft behavior remain the next two layers.

Layer 2 validation: `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes completely (components: 478 files, 3,661 tests). `pnpm format` and `pnpm run docs check` completed; docs have no errors. Packaged-device draft acceptance remains outstanding.
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并
复现:临时目录安装 `effect@3.18.4`(`npm install --prefix <temp> --ignore-scripts --no-audit --no-fund effect@3.18.4`);按仓库相对路径复制 `specs/models/session-files.effect-probe.mjs` 和 `packages/components/src/providers/store-ref-tracker.ts`;运行 `node --experimental-strip-types --test <temp>/specs/models/session-files.effect-probe.mjs`。只证明这些边界,不证明真实图片 XHR、IPC、writer 持久化、多窗口协调或 E01–E12 已验收。官方 v3 资料及当前源码入口列在 Spec 末尾。

最初设计工作树有 20 个未初始化 ACP 子模块导致的断链错误。独立实施 checkout 已初始化锁定的子模块,文档检查现在为零错误,没有注册的 SHA 保护主题。第一层的三个 actions/composer 套件共 69 项通过;全仓验证与 PR 链接随栈实施状态记录。完整 draft 产品行为和设备验收仍未完成,Spec 保持 draft,本 Note 保持 proposed。

## Layer 2 implementation

第二层为工作区创建一个 Effect 资源所有者;文件准备、图片上传及发送时的 store 借用都由它管理。React 仍使用 Promise 接口,切页不取消上传,工作区关闭先取消并等待任务,再关闭缓存和传输。不能取消的 IPC 必须结束后才能释放依赖。文件准备在新对话和继续对话间共用;取消不能触发备用上传,multipart 清理必须等待。

新增确定性测试覆盖并行取消、迟到的 store 获取、兄弟任务隔离、XHR 实际取消及上传进度与成功响应的区别。该层保持添加时上传;持久化发送和完整 draft 行为仍属于后两层。

第二层验证:`TMPDIR=/private/tmp NODE_ENV=test pnpm check` 全部通过(组件 478 个文件、3,661 个测试),`pnpm format` 和 `pnpm run docs check` 已完成;文档无错误。仍未声称完成真实设备上的 draft 验收。
6 changes: 3 additions & 3 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,12 @@
"agents.authentication.authorizeAndOpen": "Authorize and open page",
"agents.authentication.browserOpenFailed": "Could not open the authorization page. Check your browser settings and try again.",
"agents.authentication.browserOpened": "Complete authorization in the browser window, then return to Lody.",
"agents.authentication.chooseMethod": "Choose how to sign in to {{provider}}",
"agents.authentication.chooseMethod": "Choose a sign-in method",
"agents.authentication.codeCopied": "Copied",
"agents.authentication.codeSubmitFailed": "Could not submit the authorization code. Please try again.",
"agents.authentication.codeSubmitted": "Submitted",
"agents.authentication.copyCode": "Copy code",
"agents.authentication.copyCodeFailed": "Could not copy the code.",
"agents.authentication.chooseMethod": "Choose a sign-in method",
"agents.authentication.enterCode": "Enter this code on the authorization page.",
"agents.authentication.enterCodeWithExpiry": "Enter this code on the authorization page. It expires in {{minutes}} minutes.",
"agents.authentication.finishInBrowser": "Finish signing in to {{provider}}",
Expand Down Expand Up @@ -4149,5 +4148,6 @@
"promptShortcut.bodyOffline": "Body is not available offline. Reconnect and select again.",
"promptShortcut.loadFailed": "Could not load Shortcut. Select it again to retry.",
"settings.beta.promptShortcutsHelper": "Create reusable prompts and insert them with /. In development — expect rough edges.",
"settings.promptShortcuts.disabled": "Enable Prompt Shortcuts under Developer mode in Settings → About to use this feature."
"settings.promptShortcuts.disabled": "Enable Prompt Shortcuts under Developer mode in Settings → About to use this feature.",
"sessions.attachmentTransferInterrupted": "Transfer interrupted. Retry when you return."
}
6 changes: 3 additions & 3 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,12 @@
"agents.authentication.authorizeAndOpen": "授权并打开页面",
"agents.authentication.browserOpenFailed": "无法打开授权页面,请检查浏览器设置后重试。",
"agents.authentication.browserOpened": "请在浏览器窗口中完成授权,然后返回 Lody。",
"agents.authentication.chooseMethod": "选择登录 {{provider}} 的方式",
"agents.authentication.chooseMethod": "选择登录方式",
"agents.authentication.codeCopied": "已复制",
"agents.authentication.codeSubmitFailed": "无法提交授权码,请重试。",
"agents.authentication.codeSubmitted": "已提交",
"agents.authentication.copyCode": "复制代码",
"agents.authentication.copyCodeFailed": "无法复制代码。",
"agents.authentication.chooseMethod": "选择登录方式",
"agents.authentication.enterCode": "请在授权页面输入此代码。",
"agents.authentication.enterCodeWithExpiry": "请在授权页面输入此代码,代码将在 {{minutes}} 分钟后过期。",
"agents.authentication.finishInBrowser": "完成 {{provider}} 登录",
Expand Down Expand Up @@ -4149,5 +4148,6 @@
"promptShortcut.bodyOffline": "离线时无法读取正文,请联网后重新选择。",
"promptShortcut.loadFailed": "无法加载快捷方式,请重新选择以重试。",
"settings.beta.promptShortcutsHelper": "创建可复用的提示词,通过 / 插入。功能开发中,体验可能不完善。",
"settings.promptShortcuts.disabled": "请在设置 → 关于的开发者模式下开启 Prompt Shortcuts。"
"settings.promptShortcuts.disabled": "请在设置 → 关于的开发者模式下开启 Prompt Shortcuts。",
"sessions.attachmentTransferInterrupted": "传输已中断,返回后可重试。"
}
1 change: 1 addition & 0 deletions packages/components/src/atoms/chat-landing-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type PendingImage = {
progress: number;
error?: string;
uploaded?: SessionImagePayload;
abort?: AbortController;
};

export type PendingFile = {
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/atoms/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SessionSendResources } from '@/lib/session-send-resources';
import type { LocalFilePreviewResource } from '@lody/shared/local-file-preview';
import type { SessionData } from '@lody/shared/session-data';
import { atom } from 'jotai';
Expand Down Expand Up @@ -171,6 +172,7 @@ export type WorkspaceRuntime = {
* The workspace id used for IndexedDB/WebSocket connections.
*/
readonly workspaceId: WorkspaceId;
readonly sendResources: SessionSendResources;
readonly repo: LoroRepo;
/** Workspace-owned, scoped LRU for owner-session file-index Flock resources. */
readonly codeCollabFileIndexCache: CodeCollabFileIndexCache;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
prepareSessionFile,
SessionFilePreparationAuthError,
} from '@/lib/session-file-preparation';
import {
useState,
useCallback,
Expand Down Expand Up @@ -107,15 +111,11 @@ import { isImeComposingKeyboardEvent } from '@/lib/ime';
import { toast } from 'sonner';
import { uploadSessionImage, validateSessionImageFile } from '@/lib/session-image-upload';
import {
computeSha256Hex,
computeTextPreviewable,
isUploadAbortedError,
isSessionFileTransferPhase,
uploadSessionFile,
SESSION_FILE_MAX_SIZE_MB,
validateSessionFile,
type SessionFileTransferPhase,
type SessionFileUploadProgress,
} from '@/lib/session-file-upload';
import { formatFileSize } from '@/lib/session-file-presentation';
import { SESSION_FILE_MAX_COUNT, SESSION_IMAGE_MAX_SIZE_BYTES } from '@lody/shared';
Expand Down Expand Up @@ -160,6 +160,7 @@ type PendingImage = {
progress: number;
error?: string;
uploaded?: SessionImagePayload;
abort?: AbortController;
};

type PendingFile = {
Expand Down Expand Up @@ -287,8 +288,11 @@ const setSessionPastedTextDrafts = (
return next;
};

const revokeImagePreviewUrls = (images: readonly Pick<PendingImage, 'previewUrl'>[]): void => {
const revokeImagePreviewUrls = (
images: readonly Pick<PendingImage, 'previewUrl' | 'abort'>[]
): void => {
for (const image of images) {
image.abort?.abort();
URL.revokeObjectURL(image.previewUrl);
}
};
Expand Down Expand Up @@ -968,7 +972,12 @@ export const SessionChatInputArea = memo(

const startUpload = useCallback(
async (targetSessionId: SessionId, localId: string, file: File) => {
if (!workspaceId || !authToken) {
if (
!workspaceId ||
!authToken ||
!workspaceRuntime ||
workspaceRuntime.workspaceId !== workspaceId
) {
capturePostHogEvent(postHog, 'session/image_upload_failed', {
channel: 'web',
entrypoint: 'session_chat',
Expand All @@ -992,9 +1001,11 @@ export const SessionChatInputArea = memo(
return;
}

const abort = new AbortController();
updatePendingImage(targetSessionId, localId, (image) => ({
...image,
status: 'uploading',
abort,
progress: 0,
error: undefined,
}));
Expand All @@ -1014,20 +1025,26 @@ export const SessionChatInputArea = memo(
const uploadStartedAtMs = getPerformanceNowMs();

try {
const uploaded = await uploadSessionImage({
workspaceId,
sessionId: targetSessionId,
token: authToken,
file,
onProgress: (progress) => {
updatePendingImage(targetSessionId, localId, (image) => ({ ...image, progress }));
},
});
const uploaded = await workspaceRuntime.sendResources.run(
(signal) =>
uploadSessionImage({
signal,
workspaceId,
sessionId: targetSessionId,
token: authToken,
file,
onProgress: (progress) => {
updatePendingImage(targetSessionId, localId, (image) => ({ ...image, progress }));
},
}),
abort.signal
);
updatePendingImage(targetSessionId, localId, (image) => ({
...image,
status: 'uploaded',
progress: 100,
uploaded,
abort: undefined,
error: undefined,
}));
capturePostHogEvent(postHog, 'session/image_upload_succeeded', {
Expand All @@ -1044,6 +1061,15 @@ export const SessionChatInputArea = memo(
upload_duration_ms: getDurationSinceMs(uploadStartedAtMs),
});
} catch (error) {
if (isUploadAbortedError(error)) {
updatePendingImage(targetSessionId, localId, (image) => ({
...image,
status: 'failed',
error: t('sessions.attachmentTransferInterrupted'),
abort: undefined,
}));
return;
}
const errorMessage = error instanceof Error ? error.message : imageUploadFailedLabel;
const reasonCode = toImageUploadReason(classifyImageUploadReason(error));
if (
Expand All @@ -1052,12 +1078,17 @@ export const SessionChatInputArea = memo(
getSessionFileDrafts(targetSessionId).length < SESSION_FILE_MAX_COUNT
) {
try {
const outcome = await sendSessionFileToLocalRuntime({
workspaceId,
sessionId: targetSessionId,
machineId: session.machineId,
file,
});
const outcome = await workspaceRuntime.sendResources.run(
(signal) =>
sendSessionFileToLocalRuntime({
signal,
workspaceId,
sessionId: targetSessionId,
machineId: session.machineId,
file,
}),
abort.signal
);
const localFile = outcome?.ok ? outcome.files[0] : undefined;
if (localFile) {
updatePendingImagesForSession(targetSessionId, (prev) => {
Expand Down Expand Up @@ -1144,52 +1175,13 @@ export const SessionChatInputArea = memo(
updatePendingImagesForSession,
t,
workspaceId,
workspaceRuntime,
]
);

const startFileUpload = useCallback(
async (targetSessionId: SessionId, localId: string, file: File) => {
if (!workspaceId) {
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: 'failed',
progress: 0,
error: fileUploadMissingAuthLabel,
}));
return;
}

// Desktop local-transport fast path: hand bytes straight to the local CLI
// (zero relay round trip). The CLI stores the blob and returns a
// transport:'local' block, which we drop into `uploaded` exactly like a
// cloud upload — the block then rides the outgoing message via
// toFileInputBlock. No progress bar: the handoff completes in one step.
// On any failure we fall through to the cloud path below.
if (canSendFileLocally && session.machineId) {
try {
const outcome = await sendSessionFileToLocalRuntime({
workspaceId,
sessionId: targetSessionId,
machineId: session.machineId,
file,
});
if (outcome?.ok && outcome.files[0]) {
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: 'uploaded',
progress: 100,
uploaded: outcome.files[0],
error: undefined,
abort: undefined,
}));
return;
}
} catch {
// Local handoff threw; fall back to the cloud upload path.
}
}

if (!authToken) {
if (!workspaceId || !workspaceRuntime || workspaceRuntime.workspaceId !== workspaceId) {
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: 'failed',
Expand All @@ -1209,30 +1201,15 @@ export const SessionChatInputArea = memo(
}));

try {
// Compute the integrity hash + text-previewability once before upload;
// both ride along to the server and the latter pre-fills the block.
const [sha256, textPreview] = await Promise.all([
computeSha256Hex(file, {
signal: abort.signal,
onProgress: (progress) => {
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: progress.phase,
progress: progress.percent,
}));
},
}),
computeTextPreviewable(file),
]);
const uploaded = await uploadSessionFile({
const uploaded = await prepareSessionFile(workspaceRuntime.sendResources, {
workspaceId,
sessionId: targetSessionId,
machineId: session.machineId ?? null,
canSendLocally: canSendFileLocally,
token: authToken,
file,
sha256,
textPreview,
signal: abort.signal,
onProgress: (progress: SessionFileUploadProgress) => {
onProgress: (progress) => {
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: progress.phase,
Expand All @@ -1250,11 +1227,20 @@ export const SessionChatInputArea = memo(
}));
} catch (error) {
if (isUploadAbortedError(error)) {
// Removal/clearing aborts in-flight uploads; the entry is already
// gone, so leave state untouched.
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: 'failed',
error: t('sessions.attachmentTransferInterrupted'),
abort: undefined,
}));
return;
}
const errorMessage = error instanceof Error ? error.message : fileUploadFailedLabel;
const errorMessage =
error instanceof SessionFilePreparationAuthError
? fileUploadMissingAuthLabel
: error instanceof Error
? error.message
: fileUploadFailedLabel;
updatePendingFile(targetSessionId, localId, (entry) => ({
...entry,
status: 'failed',
Expand All @@ -1272,6 +1258,8 @@ export const SessionChatInputArea = memo(
session.machineId,
updatePendingFile,
workspaceId,
workspaceRuntime,
t,
]
);

Expand Down
Loading
Loading