Skip to content

Commit 3e2e482

Browse files
fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) (#877)
* fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) The legacy-to-ACP migrator's `findLegacyChatStore()` walks `~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via `readdirSync()` and returns the FIRST match. When the same cursor session id exists in more than one workspace-hash drawer (operator opened the session from a worktree, an old workspace clone, etc.) the readdir order picks an arbitrary candidate. The migrator then transplants alien content into the ACP target, deletes the source drawer, and reports success - because the verify probe only checks "loads cleanly", not "loaded the right content". Operator session resurrects with no recall of its real history. Four-part fix (all four must land together): 1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`: - Optional 3rd arg = canonical workspace path (caller passes `session.metadata.path`). - Compute md5(cwd) and check that drawer FIRST. - Fall back to readdir scan only if the canonical drawer is empty. - If 2+ candidates remain after fallback, throw `AmbiguousLegacyStoreError` listing all of them (workspaceHash, sizeBytes, mtimeMs). 2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`: - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and promote `cursorMigrationState` from 'in_progress' to a new 'ambiguous' state instead of silently clearing the banner. Operator sees an actionable web-banner. 3. Size sanity check before transplant: - Compare HAPI's known message count (new `MessageStore.countMessages` + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against the candidate `store.db`'s blob count. If message count > 100 AND blob count < messageCount/4, refuse with `size_mismatch`. - Skipped when message count is 0 (brand-new session) or the dep is unwired (unit tests, CLI direct callers). 4. Diagnostic logging on every successful transplant: - `[migrator] transplanted` info log capturing cursorSessionId, picked workspaceHash, candidate count discovered, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path md5. Future regressions of this bug shape are diagnosable from `journalctl -u hapi-hub` without blob-overlap forensics. Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`: - regression guard for single-drawer discovery - canonical-path wins over readdir order - ambiguity throws with all candidates listed (3-drawer + 2-drawer no-canonical-arg variants) - canonical-path resolves ambiguity cleanly - listLegacyChatStoreCandidates enumeration - workspaceHashFromPath shape - migrateOne happy path with canonical workspace + 3 sibling decoys - migrateOne refuses with ambiguous_legacy_store (3 drawers, no canonical match) and leaves all sources untouched - migrateOne proceeds when canonical path resolves - size_mismatch refuses tiny candidate when messageCount=6000 - size_mismatch passes when candidate blob count meets the floor - size sanity skipped on messageCount=0, missing dep, throwing dep, boundary (messageCount=100) - countLegacyStoreBlobs returns counts / null on bad path And in `hub/src/sync/syncEngineAutoMigrate.test.ts`: - cursorMigrationState promoted to 'ambiguous' on ambiguous_legacy_store / size_mismatch refusals. Schema: - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'. - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains 'ambiguous_legacy_store' + 'size_mismatch'. Real-world repro (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21k-blob history, two with stale 19/568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content; resurrected session had no memory of prior history. Manual rescue completed; this fix prevents recurrence and surfaces the ambiguity to the operator instead. * fix(cursor): address cold review on migrator path-priority fix Self-review against the cold-PR rubric surfaces four polish items on the previous commit; all four addressed in-loop before push. - Major: `migrator:transplanted` candidate count was captured AFTER the source rm, so for the dominant single-candidate happy path the log reported `candidateCount=0, sourceRemoved=true`. Useless for diagnosing a future regression of the bug shape this PR is fixing. Snapshot candidates + source-side size + source-side blob count BEFORE any destructive step and use those for the log. - Minor: `sourceBytes` and `sourceBlobCount` were read from the destination path (acpSessionDir/store.db). The cp guarantees they match, but the field names imply source-side measurement. Now they measure the source directly. - Minor: `setCursorMigrationStateAmbiguous` silently returned false on cache miss / repeated version mismatch / write failure, letting the finally{} block clear the banner without any log. Now emits a warn-level log so the gap is diagnosable from journalctl. - Minor: `findLegacyChatStore` is exported public API and used as a free function in unit tests. An out-of-band caller bypassing preflightSession could pass `..` or `/etc/passwd` and have the inner `join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on- disk path. The probe is read-only `statSync` so blast radius is small, but enforce the same CURSOR_SESSION_ID_RE at the function boundary as a defence-in-depth. New unit test locks the behaviour. Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub. * fix(cursor): cold-review polish on migrator path-priority (#873) - Web `CursorMigrationBanner` now renders a "Manual review needed" state for `cursorMigrationState === 'ambiguous'` (Major #1: caller was promoting the metadata flag but no UI surfaced it). - Pin the md5-fixture contract for `workspaceHashFromPath`: raw, no-normalization, trailing-slash-distinct hashes computed via `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift that would silently revert path-priority discovery to fallback). - Snapshot full candidate set BEFORE the canonical fast-path resolves a single drawer so the `migrator:transplanted` log reports the decision-time count, not a post-rm undercount (Minor #1). - Warn log when canonical-path drawer is missing but readdir hands back exactly one candidate - regression-equivalent behaviour, but the size mismatch warrants a journalctl trail (path-normalization corner case the maintainer can grep for). - Boundary test: `messageCount = 101` (first value above the skip threshold) engages the size sanity check, pinning the cutoff contract (Nit). - Schema docstring on `cursorMigrationState` enum spelling out the banner contract per value (Nit). - syncEngine `getHapiMessageCount` warn-logs `countMessages` throws instead of silently downgrading to 0 (would chronically disable the floor). Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and tested by the operator. #873. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): correct log-search strings in ambiguous banner copy The en/zh-CN locale strings told users to grep for 'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch' but the hub emits '[migrator] ambiguous legacy store; refusing transplant' and '[migrator] size sanity check refused transplant'. Fix both locale files to quote the actual log prefix so the journalctl grep the operator is directed to actually hits. Addresses #877 bot finding (Minor). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address #877 bot Minor findings (trim + boundary guard) - Remove .trim() from canonical path before hashing: Cursor hashes raw workspace-path bytes; trimming a POSIX path with leading/ trailing spaces would hash to the wrong drawer, causing a false canonical miss and potential ambiguity refusal. - Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates: the function was exported without the same traversal-ID boundary check present in findLegacyChatStore. A future direct caller bypassing findLegacyChatStore could stat paths outside the intended <wsh>/<cursorSessionId>/store.db shape. - Move CURSOR_SESSION_ID_RE declaration above both functions that reference it so there is no temporal-dead-zone hazard. Addresses #877 bot review Minor findings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 434cd90 commit 3e2e482

12 files changed

Lines changed: 930 additions & 41 deletions

File tree

hub/src/cursor/cursorLegacyMigrator.test.ts

Lines changed: 345 additions & 3 deletions
Large diffs are not rendered by default.

hub/src/cursor/cursorLegacyMigrator.ts

Lines changed: 347 additions & 17 deletions
Large diffs are not rendered by default.

hub/src/store/messageStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getImmediateQueuedLocalMessages,
1616
countFutureScheduledBySessionIds,
1717
countFutureScheduledLocalMessages,
18+
countMessages,
1819
markMessagesInvoked,
1920
mergeSessionMessages,
2021
copyMessageToSession as copyStoredMessageToSession,
@@ -82,6 +83,10 @@ export class MessageStore {
8283
return countFutureScheduledBySessionIds(this.db, sessionIds, now)
8384
}
8485

86+
countMessages(sessionId: string): number {
87+
return countMessages(this.db, sessionId)
88+
}
89+
8590
cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult {
8691
return cancelQueuedMessage(this.db, sessionId, messageId)
8792
}

hub/src/store/messages.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,21 @@ export function getImmediateQueuedLocalMessages(
299299
return rows.map(toStoredMessage)
300300
}
301301

302+
/**
303+
* Total messages persisted for a session - any role, any state (including
304+
* future-scheduled and never-invoked queued rows). Used as the
305+
* "is this session non-trivial?" signal for the cursor migrator's size
306+
* sanity check; intentionally broad so a session with 6 000 unread agent
307+
* outputs and zero invoked user turns still counts as non-trivial.
308+
* tiann/hapi#872.
309+
*/
310+
export function countMessages(db: Database, sessionId: string): number {
311+
const row = db.prepare(
312+
'SELECT COUNT(*) AS count FROM messages WHERE session_id = ?'
313+
).get(sessionId) as { count: number } | undefined
314+
return row?.count ?? 0
315+
}
316+
302317
/** Count uninvoked local messages scheduled for a future time (session list indicator). */
303318
export function countFutureScheduledLocalMessages(
304319
db: Database,

hub/src/sync/syncEngine.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,27 @@ export class SyncEngine {
576576
if (result.result === 'success') return { ok: true }
577577
if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const }
578578
return { ok: false, reason: 'version_mismatch_or_missing' as const }
579+
},
580+
// tiann/hapi#872: size sanity check needs to compare HAPI's known
581+
// message history against the candidate legacy store's blob
582+
// count. The store-handle stays on the engine; we only thread
583+
// the count through so the migrator stays free of a direct
584+
// hub.Store dependency.
585+
getHapiMessageCount: (sessionId, _namespace) => {
586+
try {
587+
return this.store.messages.countMessages(sessionId)
588+
} catch (err) {
589+
// tiann/hapi#873 cold review: a silent 0 here trips
590+
// the migrator's "skip sanity" branch and chronically
591+
// disables the floor. Warn so a broken countMessages
592+
// (lock contention pattern, schema drift) is visible
593+
// in journalctl.
594+
console.warn('[auto-migrate] countMessages threw; size sanity skipped', {
595+
sessionId,
596+
err: err instanceof Error ? err.message : String(err)
597+
})
598+
return 0
599+
}
579600
}
580601
})
581602
}
@@ -872,6 +893,41 @@ export class SyncEngine {
872893
if (refreshed) return refreshed
873894
return session
874895
}
896+
// tiann/hapi#872: ambiguous source store OR size-mismatch
897+
// means the migrator refused to transplant likely-alien
898+
// content. Surface this to the UI banner instead of silently
899+
// clearing the in-progress flag, so the operator can act
900+
// (verify which workspace-hash drawer holds the real history,
901+
// delete the stale siblings, retry) rather than have us
902+
// silently fall back to the legacy launcher and pretend the
903+
// ambiguity never happened.
904+
if (outcome.reason === 'ambiguous_legacy_store' || outcome.reason === 'size_mismatch') {
905+
console.warn('[auto-migrate] refusing to transplant; surfacing ambiguous banner', {
906+
sessionId: session.id,
907+
reason: outcome.reason,
908+
message: outcome.message
909+
})
910+
const promoted = this.setCursorMigrationStateAmbiguous(session.id, namespace)
911+
if (promoted) {
912+
// We replaced the in-progress flag with the
913+
// ambiguous flag; the cleanup write below would
914+
// wipe both, so suppress it.
915+
bannerCleanupNeeded = false
916+
} else {
917+
// Promotion to 'ambiguous' failed (cache miss, repeated
918+
// version-mismatch, or non-version write failure). The
919+
// operator-facing warning above already fired; the
920+
// finally{} block will fall through to clear the
921+
// in-progress flag so the user is not left with a
922+
// permanent "Upgrading..." banner. Log so the gap is
923+
// diagnosable from journalctl. tiann/hapi#872.
924+
console.warn('[auto-migrate] failed to promote cursorMigrationState to "ambiguous"; banner will clear via cleanup', {
925+
sessionId: session.id,
926+
reason: outcome.reason
927+
})
928+
}
929+
return session
930+
}
875931
// Soft fail — log and let the legacy launcher handle it.
876932
console.info('[auto-migrate] legacy cursor session left as stream-json', {
877933
sessionId: session.id,
@@ -894,6 +950,38 @@ export class SyncEngine {
894950
return session
895951
}
896952

953+
/**
954+
* Replace `cursorMigrationState='in_progress'` with `'ambiguous'` so
955+
* the web banner can switch from "Upgrading..." to "Manual resolution
956+
* needed". Returns true if the new flag persisted. tiann/hapi#872.
957+
*/
958+
private setCursorMigrationStateAmbiguous(sessionId: string, namespace: string): boolean {
959+
for (let attempt = 0; attempt < 2; attempt += 1) {
960+
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
961+
?? this.sessionCache.refreshSession(sessionId)
962+
if (!latest?.metadata) return false
963+
if (latest.metadata.cursorMigrationState === 'ambiguous') return true
964+
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'ambiguous' as const }
965+
const result = this.store.sessions.updateSessionMetadata(
966+
sessionId,
967+
nextMetadata,
968+
latest.metadataVersion,
969+
namespace,
970+
{ touchUpdatedAt: false }
971+
)
972+
if (result.result === 'success') {
973+
this.sessionCache.refreshSession(sessionId)
974+
return true
975+
}
976+
if (result.result === 'version-mismatch') {
977+
this.sessionCache.refreshSession(sessionId)
978+
continue
979+
}
980+
return false
981+
}
982+
return false
983+
}
984+
897985
/**
898986
* Set `metadata.cursorMigrationState='in_progress'` on the session row
899987
* with a single retry on version-mismatch. Returns true if the flag was

hub/src/sync/syncEngineAutoMigrate.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,40 @@ describe('SyncEngine.maybeAutoMigrateLegacyCursorSession', () => {
273273
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
274274
})
275275

276+
// tiann/hapi#872: on ambiguous_legacy_store / size_mismatch
277+
// refusals the helper must REPLACE the in-progress flag with
278+
// 'ambiguous' (not clear it) so the web banner can surface an
279+
// actionable state instead of silently disappearing.
280+
it('promotes cursorMigrationState to "ambiguous" on ambiguous_legacy_store refusal (tiann/hapi#872)', async () => {
281+
const session = insertLegacy('session-ambiguous-banner')
282+
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
283+
migrateOne: async (s: Session) => ({
284+
ok: false,
285+
sessionId: s.id,
286+
reason: 'ambiguous_legacy_store',
287+
message: '3 candidates found',
288+
durationMs: 1
289+
})
290+
})
291+
await callHelper(session)
292+
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('ambiguous')
293+
})
294+
295+
it('promotes cursorMigrationState to "ambiguous" on size_mismatch refusal (tiann/hapi#872)', async () => {
296+
const session = insertLegacy('session-size-mismatch-banner')
297+
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
298+
migrateOne: async (s: Session) => ({
299+
ok: false,
300+
sessionId: s.id,
301+
reason: 'size_mismatch',
302+
message: 'candidate has 19 blobs vs 6000 messages',
303+
durationMs: 1
304+
})
305+
})
306+
await callHelper(session)
307+
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('ambiguous')
308+
})
309+
276310
it('flipCursorSessionProtocolToAcp clears cursorMigrationState in the same metadata write that flips protocol', () => {
277311
const session = insertLegacy('session-flip-clears-flag')
278312
const store = (engine as unknown as { store: Store }).store

shared/src/apiTypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export type CursorMigrateRefusalReason =
179179
| 'session_resumed_during_migrate'
180180
| 'legacy_store_modified_during_migrate'
181181
| 'cross_host_session'
182+
| 'ambiguous_legacy_store'
183+
| 'size_mismatch'
182184
| 'internal_error'
183185

184186
export const UploadFileRequestSchema = z.object({

shared/src/schemas.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ export const MetadataSchema = z.object({
3939
opencodeSessionId: z.string().optional(),
4040
cursorSessionId: z.string().optional(),
4141
cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(),
42-
cursorMigrationState: z.enum(['in_progress']).optional(),
42+
// Drives the web `CursorMigrationBanner`:
43+
// 'in_progress' = legacy-to-ACP transplant currently running; banner shows spinner + "Upgrading..."
44+
// 'ambiguous' = migrator refused to transplant (ambiguous source drawer OR size mismatch);
45+
// banner switches to "Manual review needed" until the operator resolves on disk.
46+
// undefined = no migration in flight; banner hidden.
47+
// tiann/hapi#873.
48+
cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(),
4349
kimiSessionId: z.string().optional(),
4450
tools: z.array(z.string()).optional(),
4551
slashCommands: z.array(z.string()).optional(),

web/src/components/CursorMigrationBanner.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
22
import { render, screen, cleanup } from '@testing-library/react'
33
import { I18nProvider } from '@/lib/i18n-context'
4-
import { CursorMigrationBanner, isCursorMigrationInProgress } from './CursorMigrationBanner'
4+
import { CursorMigrationBanner, isCursorMigrationAmbiguous, isCursorMigrationInProgress } from './CursorMigrationBanner'
55
import type { Metadata } from '@/types/api'
66

77
function renderWithProviders(ui: React.ReactElement) {
@@ -83,4 +83,31 @@ describe('CursorMigrationBanner', () => {
8383
const status = screen.getByRole('status')
8484
expect(status).toHaveAttribute('aria-live', 'polite')
8585
})
86+
87+
/**
88+
* tiann/hapi#873: when the migrator refuses to transplant a legacy
89+
* store (ambiguous source or size mismatch), the hub promotes
90+
* cursorMigrationState to 'ambiguous'. The banner must switch to a
91+
* "manual review needed" surface rather than disappear.
92+
*/
93+
it('renders the ambiguous banner when cursorMigrationState is ambiguous', () => {
94+
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
95+
expect(screen.getByTestId('cursor-migration-banner-ambiguous')).toBeInTheDocument()
96+
expect(screen.getByText('Cursor session upgrade needs manual review')).toBeInTheDocument()
97+
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
98+
})
99+
100+
it('uses role=alert on the ambiguous banner so it surfaces over the in-progress styling', () => {
101+
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
102+
const alert = screen.getByRole('alert')
103+
expect(alert).toHaveAttribute('aria-live', 'polite')
104+
})
105+
106+
it('isCursorMigrationAmbiguous returns true only for the ambiguous state', () => {
107+
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'ambiguous' }))).toBe(true)
108+
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'in_progress' }))).toBe(false)
109+
expect(isCursorMigrationAmbiguous(metadata())).toBe(false)
110+
expect(isCursorMigrationAmbiguous(null)).toBe(false)
111+
expect(isCursorMigrationAmbiguous(undefined)).toBe(false)
112+
})
86113
})

web/src/components/CursorMigrationBanner.tsx

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,65 @@ export function isCursorMigrationInProgress(metadata: Metadata | undefined | nul
3030
return metadata.cursorMigrationState === 'in_progress'
3131
}
3232

33+
/**
34+
* tiann/hapi#873: the migrator refused to transplant a legacy store -
35+
* either because the same cursorSessionId exists in multiple workspace-hash
36+
* drawers (`ambiguous_legacy_store`) or because the candidate's blob count
37+
* is dramatically lower than HAPI's known history (`size_mismatch`). The
38+
* hub promotes `cursorMigrationState` from 'in_progress' to 'ambiguous' so
39+
* this banner can switch from "Upgrading..." to a "manual review needed"
40+
* surface instead of disappearing silently.
41+
*/
42+
export function isCursorMigrationAmbiguous(metadata: Metadata | undefined | null): boolean {
43+
if (!metadata) return false
44+
return metadata.cursorMigrationState === 'ambiguous'
45+
}
46+
3347
export function CursorMigrationBanner({ metadata }: { metadata: Metadata | undefined | null }) {
3448
const { t } = useTranslation()
35-
if (!isCursorMigrationInProgress(metadata)) {
36-
return null
49+
if (isCursorMigrationInProgress(metadata)) {
50+
return (
51+
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
52+
<div
53+
role="status"
54+
aria-live="polite"
55+
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
56+
>
57+
<span
58+
aria-hidden="true"
59+
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
60+
/>
61+
<div className="min-w-0 flex-1">
62+
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
63+
<div className="text-xs text-[var(--app-hint)]">
64+
{t('session.cursorMigration.banner.body')}
65+
</div>
66+
</div>
67+
</div>
68+
</div>
69+
)
3770
}
38-
return (
39-
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
40-
<div
41-
role="status"
42-
aria-live="polite"
43-
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
44-
>
45-
<span
46-
aria-hidden="true"
47-
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
48-
/>
49-
<div className="min-w-0 flex-1">
50-
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
51-
<div className="text-xs text-[var(--app-hint)]">
52-
{t('session.cursorMigration.banner.body')}
71+
if (isCursorMigrationAmbiguous(metadata)) {
72+
return (
73+
<div className="px-3 pt-3" data-testid="cursor-migration-banner-ambiguous">
74+
<div
75+
role="alert"
76+
aria-live="polite"
77+
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
78+
>
79+
<span
80+
aria-hidden="true"
81+
className="mt-0.5 inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current"
82+
>!</span>
83+
<div className="min-w-0 flex-1">
84+
<div className="font-medium">{t('session.cursorMigration.bannerAmbiguous.title')}</div>
85+
<div className="text-xs text-[var(--app-hint)]">
86+
{t('session.cursorMigration.bannerAmbiguous.body')}
87+
</div>
5388
</div>
5489
</div>
5590
</div>
56-
</div>
57-
)
91+
)
92+
}
93+
return null
5894
}

0 commit comments

Comments
 (0)