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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(desktop): let the import page name the conversation it lost track of by Astro-Han · Pull Request #3075 · apache/maka · GitHub
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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand All@@ -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: '重试',
Expand All@@ -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',
Expand All@@ -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',
Expand All@@ -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<ExternalSessionImportCopy>;

Expand Down
135 changes: 100 additions & 35 deletions apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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<string | null>(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<ImportAttempt | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<ReadonlySet<string>>(new Set());
const [uncertainImports, setUncertainImports] = useState<readonly ImportAttempt[]>([]);
// 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.
Expand DownExpand Up@@ -155,33 +184,38 @@ 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
// stored either way; what must not happen is a completion from a page
// 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);
} catch (error) {
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;
Expand DownExpand Up@@ -249,6 +283,7 @@ export function ImportTasksSettingsPage(props: {
layout="fill"
size="sm"
onChange={setAdapterId}
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
Expand All@@ -259,7 +294,7 @@ export function ImportTasksSettingsPage(props: {
label={copy.includeArchived}
value={includeArchived}
onChange={setIncludeArchived}
isDisabled={catalogLoading || importingId !== null}
isDisabled={catalogLoading}
/>
</VStack>
</SettingsSection>
Expand DownExpand Up@@ -288,11 +323,27 @@ export function ImportTasksSettingsPage(props: {
<Banner status="error" title={copy.importFailedTitle} description={importError} />
)}

{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 && (
<div role="status" aria-live="polite">
<Banner
status="info"
title={copy.importInProgressTitle}
description={copy.importInProgressDescription(activeImport.name)}
/>
</div>
)}

{uncertainImports.length > 0 && (
<Banner
status="warning"
title={copy.importOutcomeUnknownTitle}
description={copy.importOutcomeUnknownDescription}
description={copy.importOutcomeUnknownDescription(
uncertainImports.map((entry) => entry.name),
)}
/>
)}

Expand DownExpand Up@@ -325,6 +376,8 @@ export function ImportTasksSettingsPage(props: {
]
.filter(Boolean)
.join(' · ');
const isImporting =
activeImport !== null && isSameAttempt(activeImport, adapterId, session);
return (
<ListItem
key={session.id}
Expand All@@ -335,15 +388,23 @@ export function ImportTasksSettingsPage(props: {
<Button
variant="secondary"
size="sm"
isLoading={importingId === session.id}
isDisabled={importingId !== null || uncertainIds.has(session.id)}
// Returned, not discarded: Astryx's Button awaits a
// promise-returning `clickAction` and drops repeat
// clicks until it settles. `void`-ing it gave that
// guarantee nothing to await, leaving double-submit to
// the `importingId` state alone -- one render behind.
clickAction={() => 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)}
Expand All@@ -356,15 +417,19 @@ export function ImportTasksSettingsPage(props: {
)}

{catalog.nextCursor !== null && adapterId !== null && (
<HStack hAlign="center">
<Button
variant="ghost"
size="sm"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
</HStack>
/* Full width and `secondary`: as a centred ghost label this read as
a caption under the list rather than the control that extends it. */
<Button
variant="secondary"
size="sm"
width="100%"
label={loadingMore ? copy.loadingMore : copy.loadMore}
isDisabled={loadingMore}
// `onClick` for the same reason as the row buttons: inside
// `clickAction`'s transition `loadingMore` commits too late to
// disable anything or to say 正在加载….
onClick={() => void loadCatalog(adapterId, catalog.nextCursor ?? undefined)}
/>
)}
</VStack>
</SettingsSection>
Expand Down
Loading