Skip to content

Commit 8a68cd9

Browse files
opt-in per-session wallet sync (issue #123) (#200)
* core/account: let migrateSessions scope to a single session The opt-in per-session sync (#123) reuses the existing re-key copy loop but must move exactly one session. An optional sessionId filters the source listing; omitted, the bulk wallet-switch behavior is unchanged. Covered by a new spec case: the scoped run copies only the named session, leaves siblings untouched, and an unknown id is a clean no-op. * surfaces/localhost: stop auto-migrating guest sessions on wallet connect Connecting a wallet used to copy every guest session into that wallet's store, silently binding pre-wallet local work to whatever identity the user connected (#123). adoptWallet now only swaps the wallet in; guest sessions stay in the device store and ride along on every sessions push tagged local: true (cached guest-only metas, spliced synchronously so SSE ordering is untouched, deduped against stale pre-connect clients). A new syncSessionToWallet message moves exactly one session through the same migrateSessions machinery and answers with sessionSynced. Sessions created while a wallet is connected are born in the wallet's store, so their auto-sync behavior is unchanged. * webview: Local tag + opt-in sync confirm for pre-wallet sessions Chat-list rows for sessions still living in the device store show a LOCAL tag with a sync mark while a wallet is connected (#123). Tapping the row opens a confirm sheet that shows the destination wallet address up front; Sync sends syncSessionToWallet for that one session and the tag clears on the sessionSynced ack. The row never opens the chat while local: the wallet runtime cannot load it, and sending into an empty same-id chat would fork the history and block the sync. Long-press delete is skipped there for the same reason. * localhost+webview: truthful sync ack, destination avatar in the confirm Review fixes on the opt-in sync flow: - migrateSessions swallows per-session faults into report.skipped, so the handler acked ok and cleared the Local tag even when nothing was copied (an unloadable guest page). The ack is now ok only when report.copied is 1; a skipped copy keeps the tag and reports a readable error. - The confirm sheet showed the destination address only; issue #123 point 3 asks for the agent identity too, so the wallet-derived avatar (same generator as the rank cards) now sits beside the address. --------- Co-authored-by: RemilioNubilio <275382225+RemilioNubilio@users.noreply.github.com>
1 parent 7faf16f commit 8a68cd9

7 files changed

Lines changed: 206 additions & 27 deletions

File tree

packages/core/src/account/migrate.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,24 @@ describe("account/migrate — session re-key between wallets", () => {
189189
expect((await storeFor(walletB).load("s-ok"))?.messages).toHaveLength(3);
190190
});
191191

192+
it("scopes to one session when a sessionId is given: siblings are untouched", async () => {
193+
const src = storeFor(walletA);
194+
await seed(src, meta("s1", "wanted", 2000), 3);
195+
await seed(src, meta("s2", "left local", 1000), 2);
196+
197+
const report = await migrateSessions(storeFor(walletA), storeFor(walletB), "s1");
198+
expect(report).toEqual({ copied: 1, skipped: 0, messages: 3 });
199+
200+
const dst = storeFor(walletB);
201+
expect((await dst.listMine()).map((s) => s.sessionId)).toEqual(["s1"]);
202+
expect((await dst.load("s1"))?.messages).toHaveLength(3);
203+
expect(await dst.load("s2")).toBeNull();
204+
205+
// An unknown id copies nothing and fails nothing.
206+
const miss = await migrateSessions(storeFor(walletA), storeFor(walletB), "nope");
207+
expect(miss).toEqual({ copied: 0, skipped: 0, messages: 0 });
208+
});
209+
192210
it("covers the guest-unlock shape: a signMessage-only device wallet migrates into a real wallet", async () => {
193211
// Mirror of the surface's deviceGuestWallet: session-key signing works, chain signing
194212
// fails closed. Migration must only ever need signMessage.

packages/core/src/account/migrate.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
// Idempotent: sessions already complete in the destination are skipped; a partial
1313
// copy (an interrupted earlier run) resumes — only the missing tail is appended,
1414
// matched by JSON prefix; a same-id session with DIFFERENT content is never touched.
15+
//
16+
// `sessionId` scopes the run to that one session (the opt-in per-session sync);
17+
// omitted, every source session is copied (the wallet-switch bulk path).
1518

1619
import type { SessionStore } from "./store.js";
1720

@@ -24,10 +27,12 @@ export interface MigrationReport {
2427
export async function migrateSessions(
2528
source: SessionStore,
2629
destination: SessionStore,
30+
sessionId?: string,
2731
): Promise<MigrationReport> {
2832
const report: MigrationReport = { copied: 0, skipped: 0, messages: 0 };
2933
const existing = new Set((await destination.listMine()).map((s) => s.sessionId));
30-
for (const meta of await source.listMine()) {
34+
const metas = await source.listMine();
35+
for (const meta of sessionId ? metas.filter((m) => m.sessionId === sessionId) : metas) {
3136
// Per-session tolerance (mirrors listMine's): one undecryptable/corrupt session
3237
// must not abort the run — count it and keep copying the healthy ones.
3338
try {

surfaces/localhost/src/index.ts

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
type ClaudeLogin,
5353
type CodexLogin,
5454
type Wallet,
55+
type SessionMeta,
5556
type GoogleLogin,
5657
type StorageConfig,
5758
switchStorage,
@@ -205,22 +206,60 @@ async function ensureGuestRuntime(): Promise<AgentRuntime> {
205206
return ensureRuntime(guest);
206207
}
207208

208-
// Preserve the value-first conversation when the user unlocks. Guest pages are decrypted
209-
// with the device key and re-encrypted into the real wallet's local store; the copy loop
210-
// (dedupe/resume/fault tolerance) lives in core's migrateSessions — this owns the surface
211-
// part only: which wallets and stores are involved.
212-
async function migrateGuestSessions(realWallet: Wallet): Promise<void> {
209+
// A wallet's manual store is always keyed by its own address; keep the pairing in one place.
210+
function sessionStoreFor(w: Wallet): SessionStore {
211+
return new SessionStore(w, manualStorage(w.address));
212+
}
213+
214+
// Preserve the value-first conversation when the user opts in (issue #123). Guest pages are
215+
// decrypted with the device key and re-encrypted into the real wallet's local store; the
216+
// copy loop (dedupe/resume/fault tolerance) lives in core's migrateSessions, this owns the
217+
// surface part only: which wallets and stores are involved. Scoped to ONE session, so
218+
// connecting a wallet never adopts guest work wholesale; the user pulls sessions in one
219+
// at a time from the chat list.
220+
async function migrateGuestSession(realWallet: Wallet, sessionId: string): Promise<boolean> {
213221
const guest = await deviceGuestWallet();
214222
const report = await migrateSessions(
215-
new SessionStore(guest, manualStorage(guest.address)),
216-
new SessionStore(realWallet, manualStorage(realWallet.address)),
223+
sessionStoreFor(guest),
224+
sessionStoreFor(realWallet),
225+
sessionId,
217226
);
218-
if (report.copied || report.skipped) {
219-
console.log(
220-
`[wallet] guest migration: ${report.copied} session(s) copied ` +
221-
`(${report.messages} messages), ${report.skipped} skipped`,
222-
);
223-
}
227+
console.log(
228+
`[wallet] session sync ${sessionId.slice(0, 8)}: ${report.copied} copied ` +
229+
`(${report.messages} messages), ${report.skipped} skipped`,
230+
);
231+
// migrateSessions swallows per-session faults into report.skipped (an unloadable
232+
// guest page throws inside the loop, not out of it), so "did not throw" is NOT
233+
// "copied": only a real copy may clear the Local tag.
234+
return report.copied === 1;
235+
}
236+
237+
// Guest sessions NOT yet present in the connected wallet's store, cached at adopt/sync
238+
// time. While a wallet is connected these ride along on every `sessions` push tagged
239+
// `local: true`, so the UI can show the Local tag + per-session sync affordance. Null
240+
// while no wallet is connected (a guest's list already IS its own local sessions).
241+
let localSessions: SessionMeta[] | null = null;
242+
243+
async function refreshLocalSessions(realWallet: Wallet): Promise<void> {
244+
const guest = await deviceGuestWallet();
245+
const owned = new Set((await sessionStoreFor(realWallet).listMine()).map((s) => s.sessionId));
246+
localSessions = (await sessionStoreFor(guest).listMine()).filter((s) => !owned.has(s.sessionId));
247+
}
248+
249+
// Splice the cached guest-only sessions into a core `sessions` push, newest first and
250+
// tagged local. Pass-through for every other message and while no wallet is connected.
251+
// Synchronous (cached metas only), so SSE event ordering is untouched. Ids already in
252+
// the push are skipped: a client attached before the connect still lists from the guest
253+
// runtime until it reconnects, and appending there would duplicate every row.
254+
function withLocalSessions(msg: any): unknown {
255+
if (msg?.type !== "sessions" || !walletAddress || !localSessions?.length) return msg;
256+
const seen = new Set(msg.list.map((s: SessionMeta) => s.sessionId));
257+
const extras = localSessions.filter((s) => !seen.has(s.sessionId));
258+
if (!extras.length) return msg;
259+
const list = [...msg.list, ...extras.map((s) => ({ ...s, local: true }))].sort(
260+
(a, b) => b.ts - a.ts,
261+
);
262+
return { ...msg, list };
224263
}
225264

226265
// Latest drive-mirror sync result + the hook the active chat sets to surface it
@@ -371,22 +410,27 @@ async function submitGoogleAuthCode(c: Client, code: string) {
371410
}
372411

373412
// The one path a wallet takes to become THE connected wallet, whatever produced it
374-
// (external web/MWA wallet or a device-local keypair). Migrate the guest's work into the
375-
// new wallet's store, swap it in, and rebuild the runtime so the WebView reopens straight
376-
// into the unlocked state. Idempotent per host: re-connecting the same address is a no-op
377-
// so re-opened tabs don't rebuild. Adapters below build the Wallet; this owns the connect.
413+
// (external web/MWA wallet or a device-local keypair). Swap it in and rebuild the runtime
414+
// so the WebView reopens straight into the unlocked state. Guest sessions are NOT migrated
415+
// here (issue #123): connecting a wallet must not silently bind the device's local chats
416+
// to that identity. They stay in the guest store, listed with a Local tag, and move only
417+
// through the explicit syncSessionToWallet flow. Sessions created from here on are born
418+
// in the wallet's store, so those keep syncing automatically as before. Idempotent per
419+
// host: re-connecting the same address is a no-op so re-opened tabs don't rebuild.
420+
// Adapters below build the Wallet; this owns the connect.
378421
async function adoptWallet(connected: Wallet, address: string): Promise<void> {
379422
if (walletAddress === address) return;
380-
// A damaged guest store must never lock the user out of connecting — the guest copy
381-
// stays on disk, so a later connect can retry the migration.
382-
try {
383-
await migrateGuestSessions(connected);
384-
} catch (e) {
385-
console.error("[wallet] guest session migration failed:", e);
386-
}
387423
wallet = connected;
388424
walletAddress = address;
389425
walletEpoch += 1;
426+
// A damaged guest store must never lock the user out of connecting; tagging is
427+
// best-effort and recomputed on the next connect.
428+
try {
429+
await refreshLocalSessions(connected);
430+
} catch (e) {
431+
localSessions = [];
432+
console.error("[wallet] local session listing failed:", e);
433+
}
390434
await rebuildRuntime(connected);
391435
}
392436

@@ -994,7 +1038,9 @@ function attachMarketHandlers(c: Client) {
9941038
// arrives via the same onRecv (POST), so TransportApprovalChannel is unchanged.
9951039
function attachChat(id: string, c: Client, rt: AgentRuntime) {
9961040
const transport = {
997-
send: (msg: unknown) => c.send(msg),
1041+
// Local (guest) sessions ride along on the dispatcher's sessions pushes while a
1042+
// wallet is connected (see withLocalSessions); everything else passes through.
1043+
send: (msg: unknown) => c.send(withLocalSessions(msg)),
9981044
// Subscribe (don't replace): both the dispatcher and the approval channel register a
9991045
// handler on the same transport. POST fans out to all of them.
10001046
onRecv: (cb: (m: any) => void) => { c.recvs.push(cb); },
@@ -1033,6 +1079,7 @@ function attachChat(id: string, c: Client, rt: AgentRuntime) {
10331079
await clearWalletMode(); // explicit disconnect = the standing choice is gone
10341080
await disconnectCloud();
10351081
walletAddress = null;
1082+
localSessions = null; // back to guest: its own list IS the local sessions
10361083
runtime = null;
10371084
wallet = await deviceGuestWallet();
10381085
walletEpoch += 1;
@@ -1143,6 +1190,28 @@ function attachWalletConnection(c: Client) {
11431190
await pushCliStatus(c);
11441191
return;
11451192
}
1193+
// Opt-in per-session sync (issue #123): copy ONE local (guest) session into the
1194+
// connected wallet's store. The reused migrateSessions machinery is idempotent, so
1195+
// a retry after a partial copy resumes instead of duplicating. On success the tag
1196+
// cache drops the session; the UI clears its Local tag off the sessionSynced ack.
1197+
if (m?.type === "syncSessionToWallet" && typeof m.sessionId === "string") {
1198+
if (!wallet || !walletAddress) {
1199+
c.send({ type: "sessionSynced", sessionId: m.sessionId, ok: false, error: "Connect a wallet first." });
1200+
return;
1201+
}
1202+
try {
1203+
const copied = await migrateGuestSession(wallet, m.sessionId);
1204+
if (!copied) {
1205+
c.send({ type: "sessionSynced", sessionId: m.sessionId, ok: false, error: "Could not read this session from local storage." });
1206+
return;
1207+
}
1208+
if (localSessions) localSessions = localSessions.filter((s) => s.sessionId !== m.sessionId);
1209+
c.send({ type: "sessionSynced", sessionId: m.sessionId, ok: true });
1210+
} catch (e) {
1211+
c.send({ type: "sessionSynced", sessionId: m.sessionId, ok: false, error: (e as Error).message });
1212+
}
1213+
return;
1214+
}
11461215
if (m?.type !== "connectWallet" || typeof m.address !== "string" || !Array.isArray(m.signature)) return;
11471216
// A SILENT restore must never override the user's standing choice: drop it when a
11481217
// wallet is already connected (the boot local reconnect won) or the persisted mode

surfaces/webview/src/chat/Sessions.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useRef, type ReactNode, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
2+
import { walletAvatarSvg } from "../market/walletAvatar";
23
import { useStore } from "../state/store";
34
import { IqLogo, AgentIcon, LockIcon, SkillIcon } from "../icons";
45
import { useOnline } from "../layoutEffects";
@@ -19,6 +20,18 @@ function WifiOffIcon({ className, style }: { className?: string; style?: CSSProp
1920
</svg>
2021
);
2122
}
23+
24+
// circular-arrows mark for the per-session sync affordance (issue #123; inline SVG, no emoji).
25+
function SyncIcon({ className, style }: { className?: string; style?: CSSProperties }) {
26+
return (
27+
<svg className={className} style={style} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
28+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
29+
<path d="M21 3v5h-5" />
30+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
31+
<path d="M8 16H3v5" />
32+
</svg>
33+
);
34+
}
2235
import { forgetAndroidWallet } from "../onboarding/androidWallet";
2336
import { openExternalUrl } from "../platform/openExternalUrl";
2437
import { useAutoOpenExternalUrl } from "../platform/useAutoOpenExternalUrl";
@@ -171,6 +184,10 @@ export function Sessions({
171184
// Long-press a chat row to reveal a delete menu (replaces the always-on per-row x).
172185
// `pressFired` suppresses the row's open-on-click that would otherwise follow pointerup.
173186
const [menuFor, setMenuFor] = useState<{ id: string; title: string } | null>(null);
187+
// Confirm sheet for the opt-in per-session sync (issue #123): tapping a Local row
188+
// opens this instead of the chat, so a pre-wallet session is never opened (and
189+
// possibly forked) under the wallet identity without an explicit yes.
190+
const [syncFor, setSyncFor] = useState<{ id: string; title: string } | null>(null);
174191
const pressTimer = useRef<number | null>(null);
175192
const pressOrigin = useRef<{ x: number; y: number } | null>(null);
176193
const pressFired = useRef(false);
@@ -369,15 +386,25 @@ export function Sessions({
369386
{state.sessions.map((s) => {
370387
const active = s.sessionId === state.activeSessionId;
371388
const running = state.sessionsRunning.includes(s.sessionId);
389+
// Pre-wallet session still in the device store (server tags these only
390+
// while a wallet is connected). Its row opens the sync confirm, never
391+
// the chat: the wallet runtime can't load it, and sending into an empty
392+
// same-id chat would fork the history and block the sync forever. No
393+
// long-press delete either; the wallet store doesn't hold this session.
394+
const local = !!s.local;
372395
return (
373396
<button
374397
key={s.sessionId}
375-
onPointerDown={(e) => startPress(e, s)}
398+
onPointerDown={(e) => { if (!local) startPress(e, s); }}
376399
onPointerMove={movePress}
377400
onPointerUp={clearPress}
378401
onPointerCancel={clearPress}
379402
onClick={() => {
380403
if (pressFired.current) { pressFired.current = false; return; }
404+
if (local) {
405+
setSyncFor({ id: s.sessionId, title: s.title || t(M.menu.untitled) });
406+
return;
407+
}
381408
send({ type: "open", sessionId: s.sessionId });
382409
onClose();
383410
}}
@@ -387,6 +414,12 @@ export function Sessions({
387414
<span className="an-term-mono min-w-0 flex-1 truncate text-[15px] font-bold" style={{ color: running ? "var(--an-run-fg)" : active ? "var(--an-term-fg)" : "var(--an-term-fg-2)" }}>
388415
{s.title || t(M.menu.untitled)}
389416
</span>
417+
{local && (
418+
<span className="an-term-mono ml-2 flex flex-none items-center gap-1.5 text-[11px] font-bold" style={{ color: "var(--an-term-fg-6)", letterSpacing: "0.5px" }}>
419+
LOCAL
420+
<SyncIcon className="h-3.5 w-3.5" style={{ color: "var(--an-green)" }} />
421+
</span>
422+
)}
390423
{running && (
391424
<span className="an-term-mono an-run ml-2 flex-none text-[11px] font-bold" style={{ color: "var(--an-run-accent)", letterSpacing: "0.5px" }}>
392425
RUN
@@ -428,6 +461,37 @@ export function Sessions({
428461
</div>
429462
</div>
430463
)}
464+
465+
{/* Opt-in per-session sync confirm (issue #123): show the destination wallet
466+
address up front, then migrate that ONE session on an explicit yes. */}
467+
{syncFor && (
468+
<div className="an-chatmenu-backdrop" onClick={() => setSyncFor(null)}>
469+
<div className="an-chatmenu" onClick={(e) => e.stopPropagation()}>
470+
<div className="an-chatmenu-title truncate">{syncFor.title}</div>
471+
<div className="px-3 pb-3">
472+
<p className="text-[12px] leading-relaxed" style={{ color: "var(--an-fg-dim)" }}>{t(M.menu.syncConfirm)}</p>
473+
{/* Destination identity, address AND the agent it renders as (issue #123
474+
point 3): the avatar is derived from the wallet, same as the rank cards. */}
475+
<div className="an-term-mono mt-2 flex items-center gap-2.5 border px-2 py-1.5 text-[11px] leading-relaxed" style={{ borderColor: "var(--an-green-line)", background: "var(--an-green-dim)", color: "var(--an-term-fg)" }}>
476+
<span className="h-7 w-7 shrink-0 overflow-hidden" style={{ border: "1px solid var(--an-line)" }} aria-hidden="true" dangerouslySetInnerHTML={{ __html: walletAvatarSvg(state.walletAddress ?? "") }} />
477+
<span className="break-all">{state.walletAddress}</span>
478+
</div>
479+
<div className="mt-3 flex gap-2">
480+
<button className="an-btn an-btn-outline flex-1" onClick={() => setSyncFor(null)}>{t(M.menu.syncKeepLocal)}</button>
481+
<button
482+
className="an-btn an-btn-green flex-1"
483+
onClick={() => {
484+
send({ type: "syncSessionToWallet", sessionId: syncFor.id });
485+
setSyncFor(null);
486+
}}
487+
>
488+
{t(M.menu.syncAction)}
489+
</button>
490+
</div>
491+
</div>
492+
</div>
493+
</div>
494+
)}
431495
</>
432496
) : settingsMode === "configure" ? (
433497
<div className="flex h-full flex-col">

surfaces/webview/src/i18n/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ export const M = {
213213
untitled: m("(untitled)", "(제목 없음)", "(без названия)"),
214214
newChat: m("New chat", "새 채팅", "Новый чат"),
215215
deleteChat: m("Delete chat", "채팅 삭제", "Удалить чат"),
216+
// opt-in per-session sync (issue #123): confirm sheet for pulling a Local chat
217+
// into the connected wallet. The LOCAL row tag itself is a terminal token (inline).
218+
syncConfirm: m("Sync this session with this agent?", "이 세션을 이 에이전트와 동기화할까요?", "Синхронизировать эту сессию с этим агентом?"),
219+
syncAction: m("Sync", "동기화", "Синхронизировать"),
220+
syncKeepLocal: m("Keep local", "로컬로 유지", "Оставить локально"),
216221
},
217222

218223
settings: {

surfaces/webview/src/state/store.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,14 @@ function reducer(state: State, ev: Action): State {
433433
sessionsRunning: ev.running ?? [],
434434
activeSessionId: state.activeSessionId ?? ev.activeId,
435435
};
436+
case "sessionSynced":
437+
// Per-session sync ack (issue #123). Clear the row's Local tag right away instead of
438+
// waiting for the next natural sessions push (which only fires at turn edges/ready).
439+
if (!ev.ok) return { ...state, toast: `Sync failed: ${ev.error ?? "unknown"}` };
440+
return {
441+
...state,
442+
sessions: state.sessions.map((s) => (s.sessionId === ev.sessionId ? { ...s, local: undefined } : s)),
443+
};
436444
case "loading":
437445
return { ...state, loading: true };
438446
// Optimistic session switch: the moment the user taps a chat, flip the active id (so

0 commit comments

Comments
 (0)