mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): workspace notices stay in their workspace, never over Settings (N-03); neutral repository step on an early failure (N-12)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => <span hidden />, {
|
||||
id: entry.internalId,
|
||||
unstyled: true,
|
||||
className: 'cloud-toast-gone'
|
||||
})
|
||||
toast.dismiss(entry.internalId)
|
||||
entry.generation += 1
|
||||
entry.internalId = `${logicalId}#${entry.generation}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts sonner right: cards that may not show (another page, another workspace) leave quietly, and
|
||||
* everything from the first misplaced card up is re-added on top in priority order.
|
||||
*/
|
||||
function reconcile(now: number): void {
|
||||
const desired = desiredOrder()
|
||||
sonnerOrder = sonnerOrder.filter((id) => entries.has(id))
|
||||
for (const id of sonnerOrder) {
|
||||
const entry = entries.get(id)
|
||||
if (entry && !desired.includes(id)) {
|
||||
removeQuietly(id, entry)
|
||||
}
|
||||
}
|
||||
sonnerOrder = sonnerOrder.filter((id) => desired.includes(id))
|
||||
let firstDiff = 0
|
||||
while (
|
||||
firstDiff < desired.length &&
|
||||
@@ -121,37 +164,66 @@ function reconcile(now: number): void {
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
const wasShown = sonnerOrder.includes(id)
|
||||
if (wasShown) {
|
||||
if (sonnerOrder.includes(id)) {
|
||||
// Take the old card away without its exit animation, then add the new one on top.
|
||||
toast.custom(() => <span hidden />, {
|
||||
id: entry.internalId,
|
||||
unstyled: true,
|
||||
className: 'cloud-toast-gone'
|
||||
})
|
||||
toast.dismiss(entry.internalId)
|
||||
entry.generation += 1
|
||||
entry.internalId = `${id}#${entry.generation}`
|
||||
removeQuietly(id, entry)
|
||||
}
|
||||
// Already seen once: it reappears in place, unannounced.
|
||||
const returning = entry.everShown
|
||||
if (returning) {
|
||||
entry.quietUntil = now + READD_QUIET_MS
|
||||
}
|
||||
addToSonner(id, entry, wasShown)
|
||||
entry.everShown = true
|
||||
addToSonner(id, entry, returning)
|
||||
}
|
||||
sonnerOrder = desired
|
||||
}
|
||||
|
||||
/** The app's view or active workspace changed: show and hide the scoped toasts to match. */
|
||||
export function setCloudToastContext(next: CloudToastContext, now = Date.now()): void {
|
||||
if (
|
||||
next.activeView === context.activeView &&
|
||||
next.activeWorktreeId === context.activeWorktreeId
|
||||
) {
|
||||
return
|
||||
}
|
||||
context = next
|
||||
reconcile(now)
|
||||
emit()
|
||||
}
|
||||
|
||||
let contextSync: (() => void) | null = null
|
||||
|
||||
/** Follows the app's view and active workspace once any cloud toast exists. */
|
||||
function ensureContextSync(): void {
|
||||
if (contextSync) {
|
||||
return
|
||||
}
|
||||
const read = (): CloudToastContext => {
|
||||
const state = useAppStore.getState()
|
||||
return { activeView: state.activeView, activeWorktreeId: state.activeWorktreeId ?? null }
|
||||
}
|
||||
context = read()
|
||||
contextSync = useAppStore.subscribe(() => setCloudToastContext(read()))
|
||||
}
|
||||
|
||||
/** Shows or updates a persistent cloud toast. Updating keeps its place and state. */
|
||||
export function showCloudToast(
|
||||
logicalId: string,
|
||||
view: CloudToastView,
|
||||
options: { onDismiss?: () => void } = {},
|
||||
options: { onDismiss?: () => void; scope?: CloudToastScope } = {},
|
||||
now = Date.now()
|
||||
): void {
|
||||
ensureContextSync()
|
||||
const existing = entries.get(logicalId)
|
||||
if (existing) {
|
||||
const priorityChanged = existing.view.priority !== view.priority
|
||||
const reorder =
|
||||
existing.view.priority !== view.priority ||
|
||||
existing.scope?.workspaceId !== options.scope?.workspaceId
|
||||
existing.view = view
|
||||
existing.onDismiss = options.onDismiss
|
||||
if (priorityChanged) {
|
||||
existing.scope = options.scope
|
||||
if (reorder) {
|
||||
reconcile(now)
|
||||
}
|
||||
emit()
|
||||
@@ -161,6 +233,8 @@ export function showCloudToast(
|
||||
entries.set(logicalId, {
|
||||
view,
|
||||
onDismiss: options.onDismiss,
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
everShown: false,
|
||||
seq: seqCounter,
|
||||
internalId: `${logicalId}#0`,
|
||||
generation: 0,
|
||||
@@ -177,8 +251,10 @@ export function hideCloudToast(logicalId: string): void {
|
||||
return
|
||||
}
|
||||
entries.delete(logicalId)
|
||||
if (sonnerOrder.includes(logicalId)) {
|
||||
toast.dismiss(entry.internalId)
|
||||
}
|
||||
sonnerOrder = sonnerOrder.filter((id) => id !== logicalId)
|
||||
toast.dismiss(entry.internalId)
|
||||
emit()
|
||||
}
|
||||
|
||||
@@ -203,6 +279,9 @@ export function resetCloudToasts(): void {
|
||||
entries = new Map()
|
||||
sonnerOrder = []
|
||||
seqCounter = 0
|
||||
context = { activeView: 'terminal', activeWorktreeId: null }
|
||||
contextSync?.()
|
||||
contextSync = null
|
||||
emit()
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@ export function VmBuildSheet({
|
||||
: null
|
||||
}
|
||||
timing={timings[step.id] ?? null}
|
||||
stopped={failure !== null}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -43,3 +43,13 @@ it('names the repository row once: no label guessed, a placeholder only after 15
|
||||
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull()
|
||||
expect(container.textContent).toContain('Update repository')
|
||||
})
|
||||
|
||||
it('names an undecided repository step neutrally once the start has failed (N-12)', () => {
|
||||
const { container } = render(
|
||||
<ol>
|
||||
<VmBuildStepRow step={pendingClone()} cloud detail={null} timing={null} stopped t={t} />
|
||||
</ol>
|
||||
)
|
||||
expect(container.textContent).toContain('Get repository')
|
||||
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -36,10 +36,13 @@ export function VmBuildStepRow({
|
||||
cloud,
|
||||
detail,
|
||||
timing,
|
||||
stopped = false,
|
||||
t
|
||||
}: {
|
||||
step: VmBuildStep
|
||||
cloud: boolean
|
||||
/** The build stopped (a failure card shows): an undecided clone/update never will be decided. */
|
||||
stopped?: boolean
|
||||
/** The step's measured duration, when there is one. */
|
||||
timing: VmBuildStepTiming | null
|
||||
/** What the running step is doing right now, in plain words. */
|
||||
@@ -55,7 +58,9 @@ export function VmBuildStepRow({
|
||||
timing?.p95Ms ?? (cloud && step.id === 'provision' ? SLOW_WITHOUT_MEASURE_MS : null)
|
||||
const slow =
|
||||
measuring && slowAfterMs !== null && step.elapsedMs !== null && step.elapsedMs > slowAfterMs
|
||||
const modeUnknown = cloud && step.id === 'clone' && step.state === 'pending' && !step.cloneMode
|
||||
const undecided = cloud && step.id === 'clone' && step.state === 'pending' && !step.cloneMode
|
||||
// N-12: once the start has failed, the mode stays unknown for good: name the step neutrally.
|
||||
const modeUnknown = undecided && !stopped
|
||||
const placeholderDue = useDelayedFlag(modeUnknown, PLACEHOLDER_DELAY_MS)
|
||||
|
||||
return (
|
||||
@@ -94,6 +99,8 @@ export function VmBuildStepRow({
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : undecided ? (
|
||||
t('auto.components.worktree.creation.VmBuildSheet.getRepository', 'Get repository')
|
||||
) : (
|
||||
stepLabel(step, cloud, t)
|
||||
)}
|
||||
|
||||
@@ -4251,7 +4251,8 @@
|
||||
"cloneCounting": "Counting objects…",
|
||||
"updateCloud": "Update repository",
|
||||
"updateFetching": "Fetching changes…",
|
||||
"repositoryPending": "Repository"
|
||||
"repositoryPending": "Repository",
|
||||
"getRepository": "Get repository"
|
||||
},
|
||||
"VmBuildFailureCard": {
|
||||
"createFailedTitle": "Couldn’t create workspace",
|
||||
|
||||
Reference in New Issue
Block a user