diff --git a/apps/desktop/src/renderer/locales/external-session-import-copy.ts b/apps/desktop/src/renderer/locales/external-session-import-copy.ts index b5497a97a8..3b1eb0857d 100644 --- a/apps/desktop/src/renderer/locales/external-session-import-copy.ts +++ b/apps/desktop/src/renderer/locales/external-session-import-copy.ts @@ -28,10 +28,22 @@ type ExternalSessionImportCopy = { import: string; importTask: (name: string) => string; importing: string; + importInProgressTitle: string; + /** + * Named, for the same reason the unconfirmed banner names its conversations: + * the catalog is free to change while an import runs, so the row this started + * from may already be filtered or paged away. + */ + importInProgressDescription: (name: string) => string; importFailedTitle: string; importFailedFallback: string; importOutcomeUnknownTitle: string; - importOutcomeUnknownDescription: string; + /** + * Takes the conversation names because this is the only place that can say + * which ones to go look for — the rows they came from may have been filtered + * or paged away by the time it renders. + */ + importOutcomeUnknownDescription: (names: readonly string[]) => string; }; const COPY = { @@ -44,7 +56,12 @@ const COPY = { emptyTitle: '没有可导入的对话', emptyDescription: '当前来源中没有找到符合条件的根对话。', unavailableTitle: '没有检测到支持的 Agent', - unavailableDescription: 'Maka 会在本机读取 Codex 的对话目录,不会修改其中的文件。', + // The title already says nothing was detected, so this says what to do + // about it instead of saying it again. It names Codex because the renderer + // only ever learns which sources *were* detected — nothing but a copy + // string can tell someone with none what to go install. The second half is + // the promise that earns the permission to read another app's files. + unavailableDescription: '在本机使用过 Codex 后,它的对话会出现在这里。Maka 只读取这些文件,不会修改。', loadFailedTitle: '无法读取外部对话', loadFailedFallback: '外部对话目录暂时无法读取,请重试。', retry: '重试', @@ -55,11 +72,13 @@ const COPY = { import: '导入', importTask: (name) => `导入「${name}」`, importing: '正在导入…', + importInProgressTitle: '正在导入', + importInProgressDescription: (name) => `正在导入「${name}」,完成后会直接打开这个任务。`, importFailedTitle: '导入失败', importFailedFallback: '该对话无法转换或保存。请检查来源后重试。', importOutcomeUnknownTitle: '需要确认导入结果', - importOutcomeUnknownDescription: - '导入结果暂时无法确认。请先在任务列表中查找这个对话;如果它已经出现,请不要再次导入。', + importOutcomeUnknownDescription: (names) => + `以下对话的导入结果无法确认:${names.map((name) => `「${name}」`).join('、')}。请先在任务列表中查找,已经出现的不要再次导入。`, }, en: { sourceLabel: 'Source', @@ -70,7 +89,8 @@ const COPY = { emptyTitle: 'No conversations to import', emptyDescription: 'No matching root conversations were found in this source.', unavailableTitle: 'No supported Agent detected', - unavailableDescription: "Maka reads Codex's local session directory without modifying its files.", + unavailableDescription: + 'Once Codex has been used on this machine, its conversations appear here. Maka only reads those files and never modifies them.', loadFailedTitle: 'Could not read external conversations', loadFailedFallback: 'The external session directory is temporarily unavailable. Try again.', retry: 'Retry', @@ -81,11 +101,14 @@ const COPY = { import: 'Import', importTask: (name) => `Import ${name}`, importing: 'Importing…', + importInProgressTitle: 'Import in progress', + importInProgressDescription: (name) => + `Importing “${name}”. Maka opens the task as soon as it lands.`, importFailedTitle: 'Import failed', importFailedFallback: 'This conversation could not be converted or saved. Check the source and try again.', importOutcomeUnknownTitle: 'Check the import result', - importOutcomeUnknownDescription: - 'Maka could not confirm whether the import completed. Look for this conversation in the task list first; if it is already there, do not import it again.', + importOutcomeUnknownDescription: (names) => + `Maka could not confirm the outcome of these imports: ${names.map((name) => `“${name}”`).join(', ')}. Look in the task list first, and do not import again anything that is already there.`, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx index bedcf00594..15548f53c3 100644 --- a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx @@ -22,6 +22,30 @@ type CatalogState = { const EMPTY_CATALOG: CatalogState = { sessions: [], nextCursor: null }; +/** + * One conversation this page has handed to Desktop Main. + * + * The record is carried rather than the row's id alone, because everything the + * page has to say about an import — which one is running, which one came back + * unconfirmed — has to stay true after the row is gone. The archived filter, a + * source switch and a retry each replace the catalog, so a bare id is a pointer + * into a list that is allowed to change underneath it. The adapter is part of + * the record because a source-native id is unique only within its own source. + */ +type ImportAttempt = { + adapterId: string; + sourceSessionId: string; + name: string; +}; + +function isSameAttempt( + attempt: ImportAttempt, + adapterId: string | null, + session: ExternalSessionSummary, +): boolean { + return attempt.adapterId === adapterId && attempt.sourceSessionId === session.id; +} + /** * Settings · 活动 · 导入任务 — bring another local agent's conversations in as * Maka tasks. @@ -59,17 +83,22 @@ export function ImportTasksSettingsPage(props: { const [sourceResolved, setSourceResolved] = useState(false); const [catalogLoading, setCatalogLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); - const [importingId, setImportingId] = useState(null); + /** + * At most one import at a time. Not because two conversions would collide — + * Desktop Main can take both — but because the first one to succeed calls + * `onImported`, which closes Settings and opens the new task, orphaning any + * other import on a page the user can no longer see. + */ + const [activeImport, setActiveImport] = useState(null); const [sourceError, setSourceError] = useState(null); const [catalogError, setCatalogError] = useState(null); const [importError, setImportError] = useState(null); /** - * Conversations whose import neither succeeded nor failed — Desktop Main - * could not confirm the outcome. Re-importing one is how you end up with two - * copies of the same conversation, so those rows stay disabled for the rest - * of this page's lifetime and the banner says where to look instead. + * Re-importing a conversation whose outcome is unknown is how you end up with + * two copies of it, so its row stays disabled for the rest of this page's + * lifetime and the banner names it as the one to go look for. */ - const [uncertainIds, setUncertainIds] = useState>(new Set()); + const [uncertainImports, setUncertainImports] = useState([]); // Only the newest list request may write. Switching source or toggling the // archived filter while a page is in flight would otherwise land the old // source's rows under the new source's label. @@ -155,14 +184,19 @@ export function ImportTasksSettingsPage(props: { ); const importConversation = useCallback( - async (sourceSessionId: string) => { - if (adapterId === null || importingId !== null) return; - setImportingId(sourceSessionId); + async (session: ExternalSessionSummary) => { + if (adapterId === null || activeImport !== null) return; + const attempt: ImportAttempt = { + adapterId, + sourceSessionId: session.id, + name: session.name, + }; + setActiveImport(attempt); setImportError(null); try { const outcome = await window.maka.externalSessions.import({ - adapterId, - sourceSessionId, + adapterId: attempt.adapterId, + sourceSessionId: attempt.sourceSessionId, }); // Navigating away from Settings unmounts this page while the import is // still in Desktop Main's hands. The conversion itself completes and is @@ -170,7 +204,7 @@ export function ImportTasksSettingsPage(props: { // the user has left steering the shell somewhere they did not ask for. if (!mountedRef.current) return; if (!outcome.ok) { - setUncertainIds((current) => new Set(current).add(sourceSessionId)); + setUncertainImports((current) => [...current, attempt]); return; } props.onImported(outcome.session); @@ -178,10 +212,10 @@ export function ImportTasksSettingsPage(props: { if (!mountedRef.current) return; setImportError(localizedShellErrorMessage(error, copy.importFailedFallback, locale)); } finally { - if (mountedRef.current) setImportingId(null); + if (mountedRef.current) setActiveImport(null); } }, - [adapterId, copy.importFailedFallback, importingId, locale, mountedRef, props], + [activeImport, adapterId, copy.importFailedFallback, locale, mountedRef, props], ); const noSource = sourceResolved && !sourceLoading && !sourceError && adapterIds.length === 0; @@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: { layout="fill" size="sm" onChange={setAdapterId} + isDisabled={catalogLoading} > {adapterIds.map((id) => ( @@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: { label={copy.includeArchived} value={includeArchived} onChange={setIncludeArchived} - isDisabled={catalogLoading || importingId !== null} + isDisabled={catalogLoading} /> @@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: { )} - {uncertainIds.size > 0 && ( + {/* Named here rather than only on its row, because the catalog is + free to change while an import runs: filter it out, switch source, + retry a failed page, and the row is gone. This is also what tells + the user why every remaining 导入 is disabled. */} + {activeImport !== null && ( +
+ +
+ )} + + {uncertainImports.length > 0 && ( entry.name), + )} /> )} @@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: { ] .filter(Boolean) .join(' · '); + const isImporting = + activeImport !== null && isSameAttempt(activeImport, adapterId, session); return ( importConversation(session.id)} - label={importingId === session.id ? copy.importing : copy.import} + isLoading={isImporting} + isDisabled={ + activeImport !== null || + uncertainImports.some((entry) => + isSameAttempt(entry, adapterId, session), + ) + } + // `onClick`, not `clickAction`. Astryx runs + // `clickAction` inside a React 19 async transition, and + // React holds a transition's state updates until the + // action settles, so `setActiveImport` landed only once + // the import was already over and nothing on the page + // could tell that one was running. `clickAction` buys + // the clicked button its own pending state, and that is + // all it buys; this is a page fact, so the page owns it. + onClick={() => void importConversation(session)} + label={isImporting ? copy.importing : copy.import} // Every row's button reads 导入; only the accessible // name can say which conversation it imports. aria-label={copy.importTask(session.name)} @@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: { )} {catalog.nextCursor !== null && adapterId !== null && ( - -