Skip to content
Merged
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
80 changes: 80 additions & 0 deletions packages/components/src/atoms/chat-landing-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
import type { SessionFilePayload, SessionId, SessionImagePayload } from '@lody/shared';
import type { SessionFileTransferPhase } from '@/lib/session-file-upload';

/**
* In-memory chat-landing draft state that must outlive the landing route's
* unmount. The prompt text already survives through
* `chatLandingSessionStateAtomFamily`; attachments and the reserved draft
* session id used to live in component state, so navigating to another tab and
* back silently dropped them (#242).
*
* Deliberately NOT `atomWithStorage`: a preview `blob:` URL and an in-flight
* upload's `AbortController` cannot be serialized. Surviving a route unmount is
* the whole requirement — losing a draft attachment when the app restarts is
* expected.
*/

export type PendingImage = {
localId: string;
previewUrl: string;
file: File;
status: 'uploading' | 'uploaded' | 'failed';
progress: number;
error?: string;
uploaded?: SessionImagePayload;
};

export type PendingFile = {
localId: string;
file: File;
status: SessionFileTransferPhase | 'uploaded' | 'failed';
progress: number;
error?: string;
uploaded?: SessionFilePayload;
abort?: AbortController;
};

/**
* The one home for the landing draft's attachment scope. Attachments are
* uploaded into a specific workspace — an `imageId`/`fileId` from one workspace
* cannot be attached to a session in another — so unlike the prompt text (keyed
* by user alone) the attachment draft is workspace-scoped.
*
* Keyed on the workspace SLUG rather than the resolved id: the slug is a route
* param that is stable for the whole mount, while `useResolvedWorkspaceScope()`
* reports `null` until the workspace resolves. A key that flips mid-mount would
* strand whatever was added before it settled.
*
* `stateKey` is the prompt text's own key — the one passed to
* `chatLandingSessionStateAtomFamily`, which an alternate landing surface may
* suffix so it does not clobber the main draft. Pass that, not a raw user id, or
* a suffixed surface would share this scope with the main landing.
*/
export const buildChatLandingDraftKey = (stateKey: string | null, workspaceSlug: string): string =>
`${stateKey ?? 'anonymous'}:${workspaceSlug}`;

export const chatLandingPendingImagesAtomFamily = atomFamily((_draftKey: string) =>
atom<PendingImage[]>([])
);

export const chatLandingPendingFilesAtomFamily = atomFamily((_draftKey: string) =>
atom<PendingFile[]>([])
);

export const chatLandingDraftSessionIdAtomFamily = atomFamily((_draftKey: string) =>
atom<SessionId | null>(null)
);

/**
* The `resetDraftKey` this scope has already been cleared for. It lives beside
* the draft rather than in a mount-scoped ref because the draft now outlives the
* route: a `New chat` URL keeps its `resetDraftKey` in the history entry, so
* navigating back to it would otherwise re-apply the same reset and destroy the
* draft this module exists to preserve — revoking its preview URLs and aborting
* its uploads on the way out.
*/
export const chatLandingAppliedResetKeyAtomFamily = atomFamily((_draftKey: string) =>
atom<string | null>(null)
);
16 changes: 16 additions & 0 deletions packages/components/src/components/chat/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@
that same identity. Attachment hooks never reset it independently; reset only
after full draft clear. Submit blocks while either `hasBlockingImages` or
`hasBlockingFiles`.
- The reserved session id and both attachment lists live in module-level atoms
(`atoms/chat-landing-draft.ts`), keyed by `buildChatLandingDraftKey`, so the
draft survives the landing route unmounting when the user visits another tab
— the prompt text always did, and an attachment that silently vanished was
sent-without-context waiting to happen (#242). Consequences the hooks must
keep: NO unmount cleanup (revoking a preview URL or aborting an upload on the
way out is what broke the returning draft), so `URL.revokeObjectURL` and
`AbortController.abort()` belong only to removing one attachment or clearing
the whole draft; an upload in flight at unmount keeps running and settles into
the atom. That key is workspace-scoped while the prompt-text key is not,
because an `imageId`/`fileId` is addressable only inside the workspace it was
uploaded to. It scopes on the workspace SLUG: `useResolvedWorkspaceScope()`
reports `null` until the workspace resolves, and a key that flipped mid-mount
would strand whatever was added first. Never persist these to localStorage —
a `blob:` URL and an `AbortController` do not serialize, and losing a draft
attachment on app restart is expected.
- Submit immediately hides and disables the visible landing draft, but preserves
its controlled text, attachment resources, and reserved session id until
`startSession` accepts. Failure must reveal the unchanged draft; only acceptance
Expand Down
25 changes: 20 additions & 5 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
import {
buildPendingUserHistoryEntry,
buildSessionPreparationRunConfig,
Expand Down Expand Up @@ -201,6 +201,10 @@ import {
arePersistedMentionRangesEqual,
toPersistedMentionRanges,
} from '@/components/mentions/mention-persistence';
import {
buildChatLandingDraftKey,
chatLandingAppliedResetKeyAtomFamily,
} from '@/atoms/chat-landing-draft';
import { useChatLandingImageDraft } from '@/hooks/use-chat-landing-image-draft';
import { useChatLandingFileDraft } from '@/hooks/use-chat-landing-file-draft';
import { useChatLandingDraftSession } from '@/hooks/use-chat-landing-draft-session';
Expand Down Expand Up @@ -955,6 +959,12 @@ function WorkspaceChatLanding({
const [sessionState, setSessionState] = useAtom(
chatLandingSessionStateAtomFamily(chatLandingStateKey)
);
/**
* Scope for the attachment draft and the reserved session id. Unlike the
* prompt text this is workspace-scoped, because an uploaded image/file is
* addressable only inside the workspace it was uploaded to.
*/
const chatLandingDraftKey = buildChatLandingDraftKey(chatLandingStateKey, workspaceSlug);
const prompt = sessionState.prompt;
const [draftActivityRevision, setDraftActivityRevision] = useState(0);
const pastedTextDrafts = useMemo(
Expand Down Expand Up @@ -1279,7 +1289,7 @@ function WorkspaceChatLanding({
sessionId: draftSessionId,
ensureSessionId: ensureDraftSessionId,
resetSessionId: resetDraftSessionId,
} = useChatLandingDraftSession();
} = useChatLandingDraftSession(chatLandingDraftKey);
const attachmentInputRef = useRef<HTMLInputElement>(null);
const {
imageItems,
Expand All @@ -1293,6 +1303,7 @@ function WorkspaceChatLanding({
clearPendingImages,
buildInputBlocks,
} = useChatLandingImageDraft({
draftKey: chatLandingDraftKey,
workspaceId: (workspaceId as WorkspaceId | null) ?? null,
authToken,
isMobile,
Expand All @@ -1311,24 +1322,26 @@ function WorkspaceChatLanding({
clearPendingFiles,
buildFileInputBlocks,
} = useChatLandingFileDraft({
draftKey: chatLandingDraftKey,
workspaceId: (workspaceId as WorkspaceId | null) ?? null,
authToken,
machineId: selectedMachineId,
sessionId: draftSessionId,
ensureSessionId: ensureDraftSessionId,
});
const lastAppliedResetDraftKeyRef = useRef<string | null>(null);
const draftStore = useStore();
const appliedResetKeyAtom = chatLandingAppliedResetKeyAtomFamily(chatLandingDraftKey);

useEffect(() => {
if (!resetDraftKey) {
return;
}

const scopedResetKey = `${chatLandingStateKey ?? 'anonymous'}:${resetDraftKey}`;
if (lastAppliedResetDraftKeyRef.current === scopedResetKey) {
if (draftStore.get(appliedResetKeyAtom) === scopedResetKey) {
return;
}
lastAppliedResetDraftKeyRef.current = scopedResetKey;
draftStore.set(appliedResetKeyAtom, scopedResetKey);
if (resetDraftOnKeyChange) {
setSessionState({ prompt: '', pastedTextDrafts: [] });
}
Expand All @@ -1337,9 +1350,11 @@ function WorkspaceChatLanding({
clearPendingFiles();
resetDraftSessionId();
}, [
appliedResetKeyAtom,
chatLandingStateKey,
clearPendingFiles,
clearPendingImages,
draftStore,
resetDraftSessionId,
resetDraftKey,
resetDraftOnKeyChange,
Expand Down
30 changes: 19 additions & 11 deletions packages/components/src/hooks/use-chat-landing-draft-session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback } from 'react';
import { useAtomValue, useStore } from 'jotai';
import type { SessionId } from '@lody/shared';
import { chatLandingDraftSessionIdAtomFamily } from '@/atoms/chat-landing-draft';

function createDraftSessionId(): SessionId {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
Expand All @@ -8,22 +10,28 @@ function createDraftSessionId(): SessionId {
return `${Date.now()}-${Math.random().toString(36).slice(2)}` as SessionId;
}

export function useChatLandingDraftSession() {
const [sessionId, setSessionId] = useState<SessionId | null>(null);
const sessionIdRef = useRef<SessionId | null>(null);
/**
* The landing's reserved session id. Held in a module-level atom so the
* attachments uploaded against it stay addressable after the landing route
* unmounts and remounts; the store read also removes the state/ref pair the
* synchronous `ensureSessionId` used to need.
*/
export function useChatLandingDraftSession(draftKey: string) {
const sessionIdAtom = chatLandingDraftSessionIdAtomFamily(draftKey);
const store = useStore();
const sessionId = useAtomValue(sessionIdAtom);

const ensureSessionId = useCallback((): SessionId => {
if (sessionIdRef.current) return sessionIdRef.current;
const current = store.get(sessionIdAtom);
if (current) return current;
const next = createDraftSessionId();
sessionIdRef.current = next;
setSessionId(next);
store.set(sessionIdAtom, next);
return next;
}, []);
}, [sessionIdAtom, store]);

const resetSessionId = useCallback(() => {
sessionIdRef.current = null;
setSessionId(null);
}, []);
store.set(sessionIdAtom, null);
}, [sessionIdAtom, store]);

return { sessionId, ensureSessionId, resetSessionId };
}
66 changes: 32 additions & 34 deletions packages/components/src/hooks/use-chat-landing-file-draft.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import {
SESSION_FILE_MAX_COUNT,
type MachineId,
Expand All @@ -7,9 +7,10 @@ import {
type SessionInputBlock,
type WorkspaceId,
} from '@lody/shared';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { chatLandingPendingFilesAtomFamily, type PendingFile } from '@/atoms/chat-landing-draft';
import { localMachineIdAtom } from '@/atoms/local-probe';
import { formatFileSize } from '@/lib/session-file-presentation';
import {
Expand All @@ -28,16 +29,6 @@ import {
type SessionFileUploadProgress,
} from '@/lib/session-file-upload';

type PendingFile = {
localId: string;
file: File;
status: SessionFileTransferPhase | 'uploaded' | 'failed';
progress: number;
error?: string;
uploaded?: SessionFilePayload;
abort?: AbortController;
};

export type ChatLandingFileDraftItem = {
id: string;
name: string;
Expand Down Expand Up @@ -81,6 +72,8 @@ const createLocalFileId = (): string => {
* support for removal mid-flight.
*/
export function useChatLandingFileDraft(args: {
/** Scope shared with the sibling image draft and the reserved session id. */
draftKey: string;
workspaceId: WorkspaceId | null;
authToken: string | null;
/** Selected machine for the eventual session; enables the local fast path. */
Expand All @@ -90,9 +83,16 @@ export function useChatLandingFileDraft(args: {
ensureSessionId: () => SessionId;
}) {
const { t } = useTranslation();
const { workspaceId, authToken, machineId, sessionId: draftSessionId, ensureSessionId } = args;
const {
draftKey,
workspaceId,
authToken,
machineId,
sessionId: draftSessionId,
ensureSessionId,
} = args;
const localMachineId = useAtomValue(localMachineIdAtom);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [pendingFiles, setPendingFiles] = useAtom(chatLandingPendingFilesAtomFamily(draftKey));

// Desktop local-transport fast path: available only when the selected machine
// is this machine's local CLI and the Electron preload bridge exposes the
Expand All @@ -116,26 +116,21 @@ export function useChatLandingFileDraft(args: {
}
return [];
});
}, []);
}, [setPendingFiles]);

useEffect(() => {
return () => {
setPendingFiles((prev) => {
for (const entry of prev) {
entry.abort?.abort();
}
return [];
});
};
}, []);
// No unmount cleanup: the draft outlives the landing route (#242). An upload
// still in flight when the user switches tabs keeps running and settles into
// the atom, so returning shows the finished attachment rather than an entry
// aborted on the way out. Aborting stays tied to the user's own actions —
// removing one file, or clearing the draft (send accepted / draft reset).

const updatePendingFile = useCallback(
(localId: string, updater: (file: PendingFile) => PendingFile) => {
setPendingFiles((prev) =>
prev.map((entry) => (entry.localId === localId ? updater(entry) : entry))
);
},
[]
[setPendingFiles]
);

const startUpload = useCallback(
Expand Down Expand Up @@ -310,16 +305,19 @@ export function useChatLandingFileDraft(args: {
void startUpload(entry.localId, entry.file, sessionId);
}
},
[ensureSessionId, pendingFiles.length, startUpload, t]
[ensureSessionId, pendingFiles.length, setPendingFiles, startUpload, t]
);

const handleRemoveFile = useCallback((localId: string) => {
setPendingFiles((prev) => {
const target = prev.find((entry) => entry.localId === localId);
target?.abort?.abort();
return prev.filter((entry) => entry.localId !== localId);
});
}, []);
const handleRemoveFile = useCallback(
(localId: string) => {
setPendingFiles((prev) => {
const target = prev.find((entry) => entry.localId === localId);
target?.abort?.abort();
return prev.filter((entry) => entry.localId !== localId);
});
},
[setPendingFiles]
);

const handleRetryFile = useCallback(
(localId: string) => {
Expand Down
Loading
Loading