From c4f0cacf42ed9fae6cd72492413a2e2aa67256f9 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 11:40:33 -0800 Subject: [PATCH] feat(cloud): workspace notices stay in their workspace, never over Settings (N-03); neutral repository step on an early failure (N-12) --- .../orca-cloud/CloudSetupNotices.tsx | 4 +- .../orca-cloud/cloud-toast-stack.test.tsx | 53 +++++++++ .../orca-cloud/cloud-toast-stack.tsx | 111 +++++++++++++++--- .../worktree-creation/VmBuildSheet.tsx | 1 + .../worktree-creation/VmBuildStepRow.test.tsx | 10 ++ .../worktree-creation/VmBuildStepRow.tsx | 9 +- src/renderer/src/i18n/locales/en.json | 3 +- 7 files changed, 172 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx index 48c1c7d4fbf..2d5cc0b13ac 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx @@ -228,7 +228,9 @@ export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }): install: () => record.repoUrl && push.startInstall(record.repoUrl), runSetup: (noticeId) => void runCloudSetupScript(workspaceId, record, noticeId), dismiss: (noticeId) => dismissCloudSetupNotice(record.environmentId, noticeId) - }) + }), + // N-03: only over its own workspace; never over Settings or another workspace. + { scope: { workspaceId } } ) }) useEffect( diff --git a/src/renderer/src/components/orca-cloud/cloud-toast-stack.test.tsx b/src/renderer/src/components/orca-cloud/cloud-toast-stack.test.tsx index 0a5b8b2cc9f..5f8533f7ce5 100644 --- a/src/renderer/src/components/orca-cloud/cloud-toast-stack.test.tsx +++ b/src/renderer/src/components/orca-cloud/cloud-toast-stack.test.tsx @@ -15,8 +15,10 @@ vi.mock('sonner', () => ({ toast: sonner })) import { cloudToastInternalId, + hideCloudToast, READD_QUIET_MS, resetCloudToasts, + setCloudToastContext, showCloudToast, type CloudToastView } from './cloud-toast-stack' @@ -163,4 +165,55 @@ describe('cloud toast stack (DS-q)', () => { expect(sonner.dismiss).toHaveBeenCalledWith(internal) expect(cloudToastInternalId(AGENT)).toBeNull() }) + + describe('workspace scope (N-03)', () => { + const W1 = 'w1' + const inWorkspace = (id: string | null) => ({ activeView: 'terminal', activeWorktreeId: id }) + + it("shows a workspace's notice only in that workspace, and quietly again on return", () => { + vi.useFakeTimers({ now: 1_000 }) + // The stack reads the app's context on its first toast; then the user is in w1. + showCloudToast(PUSH, pushView, { scope: { workspaceId: W1 } }, 1_000) + setCloudToastContext(inWorkspace(W1), 1_000) + expect(sonnerStack()).toEqual([cloudToastInternalId(PUSH)]) + const first = cloudToastInternalId(PUSH) + + // Another workspace: gone from sonner (no focus or tab stops left behind). + setCloudToastContext(inWorkspace('w2'), 1_100) + expect(sonnerStack()).toEqual([]) + expect(sonner.dismiss).toHaveBeenCalledWith(first) + + // Back: shown again, under a fresh id, without a slide-in or a second announcement. + setCloudToastContext(inWorkspace(W1), 1_200) + const back = cloudToastInternalId(PUSH) ?? '' + expect(back).not.toBe(first) + expect(sonnerStack()).toEqual([back]) + const call = sonner.custom.mock.calls.findLast(([, options]) => options.id === back) + expect(call?.[1].className).toBe('cloud-toast-readd') + const view = renderInternal(back) + expect(view.container.querySelector('[data-cloud-toast]')?.getAttribute('aria-hidden')).toBe( + 'true' + ) + }) + + it('never shows over Settings or another page, scoped or not', () => { + showCloudToast(PUSH, pushView, { scope: { workspaceId: W1 } }, 0) + showCloudToast('info', infoView, {}, 0) + setCloudToastContext(inWorkspace(W1), 0) + expect(sonnerStack()).toHaveLength(2) + setCloudToastContext({ activeView: 'settings', activeWorktreeId: W1 }, 1) + expect(sonnerStack()).toEqual([]) + setCloudToastContext(inWorkspace(W1), 2) + expect(sonnerStack()).toHaveLength(2) + }) + + it('a toast closed while hidden stays closed', () => { + showCloudToast(PUSH, pushView, { scope: { workspaceId: W1 } }, 0) + setCloudToastContext(inWorkspace(W1), 0) + setCloudToastContext(inWorkspace('w2'), 1) + hideCloudToast(PUSH) + setCloudToastContext(inWorkspace(W1), 2) + expect(sonnerStack()).toEqual([]) + }) + }) }) diff --git a/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx b/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx index 1376bc182fd..4416dccbf8b 100644 --- a/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx +++ b/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx @@ -15,6 +15,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' export type CloudToastPriority = 'action' | 'warning' | 'info' @@ -44,8 +45,17 @@ export type CloudToastView = { label?: string } +/** A toast about one workspace shows only while that workspace is on screen (N-03). */ +export type CloudToastScope = { workspaceId: string } + +/** What decides visibility: the app's view and its active workspace. */ +export type CloudToastContext = { activeView: string; activeWorktreeId: string | null } + type Entry = { view: CloudToastView + scope?: CloudToastScope + /** In sonner at least once: coming back is quiet (no re-announce, no slide-in). */ + everShown: boolean /** Called when the user closes it with ×; never on an internal re-add. */ onDismiss?: () => void seq: number @@ -65,6 +75,8 @@ let sonnerOrder: string[] = [] let seqCounter = 0 const listeners = new Set<() => void>() let version = 0 +// Until the app tells us otherwise, nothing scoped is on screen; unscoped toasts show. +let context: CloudToastContext = { activeView: 'terminal', activeWorktreeId: null } function emit(): void { version += 1 @@ -73,8 +85,17 @@ function emit(): void { } } +/** Never over Settings or any other page; a scoped toast only in its own workspace (N-03). */ +function isVisible(entry: Entry): boolean { + if (context.activeView !== 'terminal') { + return false + } + return !entry.scope || entry.scope.workspaceId === context.activeWorktreeId +} + function desiredOrder(): string[] { return [...entries.entries()] + .filter(([, entry]) => isVisible(entry)) .sort(([, a], [, b]) => RANK[a.view.priority] - RANK[b.view.priority] || a.seq - b.seq) .map(([id]) => id) } @@ -104,10 +125,32 @@ function addToSonner(logicalId: string, entry: Entry, readd: boolean): void { } } -/** Puts sonner's order right: everything from the first misplaced card up is re-added on top. */ +/** Takes a card out of sonner without its exit animation; its next appearance gets a fresh id. */ +function removeQuietly(logicalId: string, entry: Entry): void { + toast.custom(() =>