Skip to content

feat(desktop): import several conversations in one pass - #4437

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/import-multi-select
Sep 1, 2026
Merged

feat(desktop): import several conversations in one pass#4437
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/import-multi-select

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Settings › 导入任务 imported one conversation per press. A Claude Code directory here lists 1128 of them, so bringing over a project's worth meant finding a row, pressing 导入, waiting for the page to navigate away, coming back, and finding the next one.

Every row now carries a checkbox and a master row sits above the list:

[▬]  已选 15 / 16                          [ 导入所选 ]
[ ]  cyberpet      ~/CyberPet · 19:13          [ 导入 ]
[✓]  cyberpet      ~/CyberPet · 18:57          [ 导入 ]

There is no mode to enter: this page exists to pick conversations out of a directory, so the boxes are the page. The per-row 导入 button is untouched and still navigates to what it imported.

A batch stays here and reports. A single import ends by calling onImported, which closes Settings and opens the new task. There is no sensible task to open after importing twelve, and leaving would strand the rows that did not land. The summary counts four outcomes apart:

  • imported — landed.
  • duplicated — landed, and that conversation now exists twice. Re-importing is how one is refreshed, so those rows are selectable on purpose; but a user who marked twelve and reads "imported 12" deserves to know which kind they were.
  • failed — the Host rejected it. One rejection is not the batch's answer for every row after it, so the run continues.
  • unknown — the call did not answer. Only a catalog read settles whether the conversion landed, and folding this into failures is what would invite the retry that makes a second copy. These reach the existing unconfirmed banner, which names every one of them and resolves them a press at a time.

Sequential, and not because the Host cannot take two. external-session-coordinator.ts dedupes per (adapterId, sourceSessionId) and converts different conversations concurrently without complaint. The reasons are on this page: recovery re-reads the whole catalog window an attempt came from, so overlapping attempts would race that read; a progress count is only true while one thing is happening; and the page has no useful answer to "which of these five failed" if they fail together.

"All" means the rows on screen. The catalog is paged behind a cursor and narrowed by a search term and the archived filter, so a box that reached past the rows beneath it would start an import whose size the user never saw.

Paying for it in a file that may not grow

renderer-architecture.json ratchets this page's debt against main: no new stateful hooks, no new bridge call sites, no new module specifiers. Three consolidations paid for the feature, and each is worth having on its own terms.

  • sourceLoading + sourceResolved become one sourceProbe. They were always written together, and loading && resolved was never a state this page could be in with nothing enforcing it.
  • activeImport plus the batch's progress and summary become one importRun. A single import and a batch are one activity and cannot overlap; three states invited combinations that cannot happen.
  • The marked set is intersected at read time rather than pruned in an effect. That is not only a hook saved: an effect leaves a render in which a row the catalog just dropped is still counted, still named by the confirm, and still importable. A derived read has no such window.

Both import paths now go through one requestImport, which the ledger also required — and which is what two callers of the same bridge method wanted anyway.

metric main here
hookCalls 31 31
bridgePaths 4 4
dependencyPaths 19 19

The selection itself is packages/ui/src/listed-selection.ts — a marked subset of whatever a surface is listing, with no opinion about what the ids identify. Every function returns its input unchanged when nothing moved, because identity is the render boundary for a list of rows.

Self-review

Reviewing the diff before opening this turned up three defects, all fixed here.

  1. A stale doc comment left stacked above its replacement, describing an activeImport that no longer exists. Its one load-bearing fact — that the single-import limit is about navigation, not about the Host — is merged into the new comment.
  2. Every selected row spun during a batch, including rows the run had not reached and rows it had already finished. A spinner claims something is happening now, so the batch state carries the id actually converting and only that row reads as busy. A test parks the first conversion and asserts exactly one row is spinning.
  3. The batch records an unanswered import without running recovery, unlike the single path. That is deliberate — recovery re-reads the whole catalog window, and doing that between conversions would interleave N full reads with the writes the batch is making — but it was undocumented, which is how a deliberate divergence becomes a bug report.

Verification

packages/ui        304 tests, 304 pass, 0 fail
apps/desktop      1851 tests, 1849 pass, 2 fail   (environmental, see below)
check:renderer-architecture --base    pass, all three metrics level with main
astryx surface inventory + its tests  pass
astryx:theme --check                  pass
knip (apps/desktop, packages/ui)      pass
lint, format:check, typecheck         pass
check:app-shell-hooks, check:asf-headers   pass

Driven in the running app over a real Claude Code directory of 16 conversations: master box to 16/16, untick one row to 15/16 with the master reading indeterminate, and back.

The two failing app-update-attestation cases (TUF/Sigstore in the packaged Electron runtime) fail on this machine with and without these changes.

Each behaviour fails a test when reverted:

reverted result
counting a re-import as duplicated 29 pass / 1 fail
treating a rejection as an import 29 pass / 1 fail
the master box's indeterminate state 29 pass / 1 fail
spinning only the conversion in flight 30 pass / 1 fail

Relationship to #4365

No file overlaps that PR. It adds multi-select to the Session rail; this adds it to the import catalog. They share the checkbox-and-master-box shape and nothing else — the rail acts on Maka's own sessions through SessionNavigationServices, this acts on foreign conversations through window.maka.externalSessions. Either can land first; whichever lands second will need renderer-architecture.json and the Astryx inventory regenerated, which is mechanical.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — design, implementation, tests, and this description. Reviewed and verified locally by me; the commit carries a Generated-by trailer.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Settings › 导入任务 imported one conversation per press. A Claude Code
directory here lists 1128 of them, so bringing over a project's worth meant
finding a row, pressing 导入, waiting for the page to navigate away, coming
back, and finding the next one.

Every row now carries a checkbox and a master row sits above the list:

    [▬]  已选 15 / 16                          [ 导入所选 ]
    [ ]  cyberpet      ~/CyberPet · 19:13          [ 导入 ]
    [✓]  cyberpet      ~/CyberPet · 18:57          [ 导入 ]

No mode to enter: this page exists to pick conversations out of a
directory, so the boxes are the page. The per-row 导入 button is untouched
and still navigates to what it imported.

**A batch stays here and reports.** A single import ends by calling
`onImported`, which closes Settings and opens the new task — there is no
sensible task to open after importing twelve, and leaving would strand the
rows that did not land. The summary counts four outcomes apart:

- `imported` — landed.
- `duplicated` — landed, and that conversation now exists twice.
  Re-importing is how one is refreshed, so those rows are selectable on
  purpose, but a user who marked twelve and reads "imported 12" deserves
  to know which kind they were.
- `failed` — the Host rejected it. One rejection is not the batch's answer
  for every row after it, so the run continues.
- `unknown` — the call did not answer. Only a catalog read settles whether
  the conversion landed, and folding this into failures is what would
  invite the retry that makes a second copy. These reach the existing
  unconfirmed banner, which names every one of them and resolves them a
  press at a time.

**Sequential, and not because the Host cannot take two.**
`external-session-coordinator.ts` dedupes per (adapter, source id) and
converts different conversations concurrently without complaint. The
reasons are on this page: recovery re-reads the whole catalog window an
attempt came from, so overlapping attempts would race that read; a progress
count is only true while one thing is happening; and the page has no useful
answer to "which of these five failed" if they fail together.

**"All" means the rows on screen.** The catalog is paged behind a cursor
and narrowed by a search term and the archived filter, so a box that
reached past the rows beneath it would start an import whose size the user
never saw.

## Paying for it in a file that may not grow

`renderer-architecture.json` ratchets this page's debt against `main`: no
new stateful hooks, no new bridge call sites, no new module specifiers.
Three consolidations paid for the feature, and each is worth having on its
own terms:

- `sourceLoading` + `sourceResolved` become one `sourceProbe`. They were
  always written together and `loading && resolved` was never a state this
  page could be in, with nothing enforcing it.
- `activeImport` plus the batch's progress and summary become one
  `importRun`. A single import and a batch are one activity and cannot
  overlap; three states invited combinations that cannot happen.
- The marked set is INTERSECTED at read time instead of pruned in an
  effect. That is not only a hook saved: an effect leaves a render where a
  row the catalog just dropped is still counted, still named by the
  confirm, and still importable. A derived read has no such window.

Both import paths now go through one `requestImport`, which the ledger also
required — and which is what two callers of the same bridge method wanted
anyway.

The page's `hookCalls` (31), `bridgePaths` (4) and `dependencyPaths` (19)
are unchanged from `main`.

The selection itself is `packages/ui/src/listed-selection.ts` — a marked
subset of whatever a surface is listing, with no opinion about what the ids
identify. Every function returns its input unchanged when nothing moved,
because identity is the render boundary for a list of rows.

## Self-review

Reviewing the diff before opening this turned up three defects, all fixed:

1. A stale doc comment left stacked above its replacement, describing an
   `activeImport` that no longer exists. Its one load-bearing fact — that
   the single-import limit is about navigation and not about the Host — is
   merged into the new comment.
2. Every selected row spun during a batch, including rows the run had not
   reached and rows it had already finished. A spinner claims something is
   happening now, so the batch state carries the id actually converting and
   only that row reads as busy. A test drives a parked first conversion and
   asserts exactly one row is spinning.
3. The batch records an unanswered import without running recovery, unlike
   the single path — deliberate, because recovery re-reads the whole
   catalog window and doing that between conversions would interleave N
   full reads with the writes the batch is making. It was undocumented,
   which is how a deliberate divergence becomes a bug report.

## Verification

    packages/ui        304 tests, 304 pass
    apps/desktop      1851 tests, 1849 pass, 2 fail (environmental)
    check:renderer-architecture --base   pass, all three metrics level
    astryx inventory + tests, astryx:theme --check   pass
    knip (desktop, ui), lint, format:check, typecheck   pass
    check:app-shell-hooks, check:asf-headers   pass

Driven in the running app over a real Claude Code directory of 16
conversations: master box to 16/16, untick one to 15/16 with the master
reading indeterminate, and back.

The two failing `app-update-attestation` cases (TUF/Sigstore in the
packaged Electron runtime) fail on this machine with and without these
changes.

Reverting each behaviour fails a test: the duplicate count, treating a
rejection as an import, a two-state master box, and the queued-row spinner.

Generated-by: Claude Opus 5 via Claude Code
@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Sep 1, 2026

@likun666661 likun666661 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@likun666661
likun666661 merged commit fd55704 into apache:main Sep 1, 2026
4 checks passed
abhinav-phi pushed a commit to abhinav-phi/maka that referenced this pull request Sep 1, 2026
Settings › 导入任务 imported one conversation per press. A Claude Code
directory here lists 1128 of them, so bringing over a project's worth meant
finding a row, pressing 导入, waiting for the page to navigate away, coming
back, and finding the next one.

Every row now carries a checkbox and a master row sits above the list:

    [▬]  已选 15 / 16                          [ 导入所选 ]
    [ ]  cyberpet      ~/CyberPet · 19:13          [ 导入 ]
    [✓]  cyberpet      ~/CyberPet · 18:57          [ 导入 ]

No mode to enter: this page exists to pick conversations out of a
directory, so the boxes are the page. The per-row 导入 button is untouched
and still navigates to what it imported.

**A batch stays here and reports.** A single import ends by calling
`onImported`, which closes Settings and opens the new task — there is no
sensible task to open after importing twelve, and leaving would strand the
rows that did not land. The summary counts four outcomes apart:

- `imported` — landed.
- `duplicated` — landed, and that conversation now exists twice.
  Re-importing is how one is refreshed, so those rows are selectable on
  purpose, but a user who marked twelve and reads "imported 12" deserves
  to know which kind they were.
- `failed` — the Host rejected it. One rejection is not the batch's answer
  for every row after it, so the run continues.
- `unknown` — the call did not answer. Only a catalog read settles whether
  the conversion landed, and folding this into failures is what would
  invite the retry that makes a second copy. These reach the existing
  unconfirmed banner, which names every one of them and resolves them a
  press at a time.

**Sequential, and not because the Host cannot take two.**
`external-session-coordinator.ts` dedupes per (adapter, source id) and
converts different conversations concurrently without complaint. The
reasons are on this page: recovery re-reads the whole catalog window an
attempt came from, so overlapping attempts would race that read; a progress
count is only true while one thing is happening; and the page has no useful
answer to "which of these five failed" if they fail together.

**"All" means the rows on screen.** The catalog is paged behind a cursor
and narrowed by a search term and the archived filter, so a box that
reached past the rows beneath it would start an import whose size the user
never saw.

## Paying for it in a file that may not grow

`renderer-architecture.json` ratchets this page's debt against `main`: no
new stateful hooks, no new bridge call sites, no new module specifiers.
Three consolidations paid for the feature, and each is worth having on its
own terms:

- `sourceLoading` + `sourceResolved` become one `sourceProbe`. They were
  always written together and `loading && resolved` was never a state this
  page could be in, with nothing enforcing it.
- `activeImport` plus the batch's progress and summary become one
  `importRun`. A single import and a batch are one activity and cannot
  overlap; three states invited combinations that cannot happen.
- The marked set is INTERSECTED at read time instead of pruned in an
  effect. That is not only a hook saved: an effect leaves a render where a
  row the catalog just dropped is still counted, still named by the
  confirm, and still importable. A derived read has no such window.

Both import paths now go through one `requestImport`, which the ledger also
required — and which is what two callers of the same bridge method wanted
anyway.

The page's `hookCalls` (31), `bridgePaths` (4) and `dependencyPaths` (19)
are unchanged from `main`.

The selection itself is `packages/ui/src/listed-selection.ts` — a marked
subset of whatever a surface is listing, with no opinion about what the ids
identify. Every function returns its input unchanged when nothing moved,
because identity is the render boundary for a list of rows.

## Self-review

Reviewing the diff before opening this turned up three defects, all fixed:

1. A stale doc comment left stacked above its replacement, describing an
   `activeImport` that no longer exists. Its one load-bearing fact — that
   the single-import limit is about navigation and not about the Host — is
   merged into the new comment.
2. Every selected row spun during a batch, including rows the run had not
   reached and rows it had already finished. A spinner claims something is
   happening now, so the batch state carries the id actually converting and
   only that row reads as busy. A test drives a parked first conversion and
   asserts exactly one row is spinning.
3. The batch records an unanswered import without running recovery, unlike
   the single path — deliberate, because recovery re-reads the whole
   catalog window and doing that between conversions would interleave N
   full reads with the writes the batch is making. It was undocumented,
   which is how a deliberate divergence becomes a bug report.

## Verification

    packages/ui        304 tests, 304 pass
    apps/desktop      1851 tests, 1849 pass, 2 fail (environmental)
    check:renderer-architecture --base   pass, all three metrics level
    astryx inventory + tests, astryx:theme --check   pass
    knip (desktop, ui), lint, format:check, typecheck   pass
    check:app-shell-hooks, check:asf-headers   pass

Driven in the running app over a real Claude Code directory of 16
conversations: master box to 16/16, untick one to 15/16 with the master
reading indeterminate, and back.

The two failing `app-update-attestation` cases (TUF/Sigstore in the
packaged Electron runtime) fail on this machine with and without these
changes.

Reverting each behaviour fails a test: the duplicate count, treating a
rejection as an import, a two-state master box, and the queued-row spinner.

Generated-by: Claude Opus 5 via Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants