fix(cloud): W10 round 6 — shared CloudStateDot, 'Go to {name}'s chat', confirms for make private / reassign owner, grammar-aware 'you', sign-in action, presence legibility, link highlight, archived dimming

This commit is contained in:
Neil
2026-11-09 07:44:08 -08:00
parent cff627fe6a
commit 8d07fd6b09
13 changed files with 554 additions and 120 deletions
@@ -17,7 +17,7 @@ function Line({
)
}
/** Viewing (avatar only), typing (pulsing dot), overflow "+N"; teammates are Follow buttons. */
/** Viewing (avatar only), typing (pulsing dot), overflow "+N"; teammates are "Go to {name}'s chat" buttons. */
export const presenceStacks = defineFixture({
width: 480,
render: () => (
@@ -27,7 +27,7 @@ export const presenceStacks = defineFixture({
presence={[presence('user_grace')]}
directory={DIRECTORY}
selfId={SELF_ID}
onFollow={() => {}}
onGoToChat={() => {}}
/>
</Line>
<Line label="Typing + viewing">
@@ -35,7 +35,7 @@ export const presenceStacks = defineFixture({
presence={[presence('user_grace', 'typing'), presence('user_linus')]}
directory={DIRECTORY}
selfId={SELF_ID}
onFollow={() => {}}
onGoToChat={() => {}}
/>
</Line>
<Line label="With you (not a button)">
@@ -43,7 +43,7 @@ export const presenceStacks = defineFixture({
presence={[presence(SELF_ID), presence('user_mae', 'typing')]}
directory={DIRECTORY}
selfId={SELF_ID}
onFollow={() => {}}
onGoToChat={() => {}}
/>
</Line>
<Line label="Overflow">
@@ -53,14 +53,14 @@ export const presenceStacks = defineFixture({
)}
directory={DIRECTORY}
selfId={SELF_ID}
onFollow={() => {}}
onGoToChat={() => {}}
/>
</Line>
</div>
)
})
/** Follow buttons show the shared focus ring on keyboard focus. */
/** Go-to-chat buttons show the shared focus ring on keyboard focus. */
export const followFocus = defineFixture({
width: 320,
render: () => (
@@ -69,7 +69,7 @@ export const followFocus = defineFixture({
presence={[presence('user_grace', 'typing'), presence('user_linus')]}
directory={DIRECTORY}
selfId={SELF_ID}
onFollow={() => {}}
onGoToChat={() => {}}
/>
</div>
),
@@ -1,4 +1,5 @@
import React from 'react'
import { ArrowUpRight } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/utils'
@@ -53,20 +54,20 @@ export function MemberAvatar({
}
/**
* Who is in the workspace right now, one avatar per member. A teammate's avatar is a button that
* follows them (opens the workspace on the chat they have in front).
* Who is in the workspace right now, one avatar per member, each with its own tooltip. A
* teammate's avatar is a button that jumps once to the chat they have in front.
*/
export function PresenceStack({
presence,
directory,
selfId,
onFollow,
onGoToChat,
max = 3
}: {
presence: CloudWorkspacePresence[]
directory: MemberDirectory
selfId: string | null
onFollow?: (member: CloudWorkspacePresence) => void
onGoToChat?: (member: CloudWorkspacePresence) => void
max?: number
}): React.JSX.Element | null {
const t = useTranslate()
@@ -77,53 +78,69 @@ export function PresenceStack({
}
const shown = members.slice(0, max)
const extra = members.length - shown.length
const summary = presenceSummary(members, directory, selfId, t, intlLocale)
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center -space-x-1.5">
{/* Viewing is the avatar alone; typing rings it and adds a pulsing dot. Neither reuses
the workspace status colors, so presence never reads as "running" or "failed". */}
{shown.map((p) => {
const avatar = (
<>
<MemberAvatar userId={p.userId} directory={directory} selfId={selfId} size="sm" />
{p.state === 'typing' && (
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-primary ring-2 ring-background motion-safe:animate-pulse" />
)}
</>
)
return onFollow && p.userId !== selfId ? (
<span className="flex items-center -space-x-1">
{/* Viewing is the avatar alone; typing adds a pulsing dot. Neither reuses the workspace
status colors, so presence never reads as "running" or "failed". */}
{shown.map((p) => {
const status = presenceSummary([p], directory, selfId, t, intlLocale)
const avatar = (
<>
<MemberAvatar userId={p.userId} directory={directory} selfId={selfId} size="md" />
{p.state === 'typing' && (
<span className="absolute -bottom-0.5 -right-0.5 z-10 size-2 rounded-full bg-agent-working ring-2 ring-background motion-safe:animate-pulse" />
)}
</>
)
if (!onGoToChat || p.userId === selfId) {
return (
<Tooltip key={p.userId}>
<TooltipTrigger asChild>
<span
role="img"
aria-label={status}
className="relative rounded-full ring-2 ring-background"
>
{avatar}
</span>
</TooltipTrigger>
<TooltipContent side="top">{status}</TooltipContent>
</Tooltip>
)
}
const goTo = t('auto.components.cloudWorkspaces.goToChat', "Go to {{value0}}'s chat", {
value0: memberName(directory, p.userId, selfId, t)
})
return (
<Tooltip key={p.userId}>
<TooltipTrigger asChild>
<button
key={p.userId}
type="button"
onClick={() => onFollow(p)}
aria-label={t('auto.components.cloudWorkspaces.followMember', 'Follow {{value0}}', {
value0: memberName(directory, p.userId, selfId, t)
})}
onClick={() => onGoToChat(p)}
aria-label={goTo}
className="relative cursor-default rounded-full ring-2 ring-background outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
>
{avatar}
</button>
) : (
<span
key={p.userId}
role="img"
aria-label={presenceSummary([p], directory, selfId, t, intlLocale)}
className="relative rounded-full ring-2 ring-background"
>
{avatar}
</TooltipTrigger>
<TooltipContent side="top">
<span className="flex items-center gap-1">
<ArrowUpRight className="size-3" aria-hidden />
{goTo}
</span>
)
})}
{extra > 0 && (
<span className="relative inline-flex size-5 items-center justify-center rounded-full bg-muted text-3xs font-medium text-muted-foreground ring-2 ring-background">
+{extra}
</span>
)}
<span className="block opacity-80">{status}</span>
</TooltipContent>
</Tooltip>
)
})}
{extra > 0 && (
<span className="relative inline-flex size-6 items-center justify-center rounded-full bg-muted text-3xs font-medium text-muted-foreground ring-2 ring-background">
<span aria-hidden>+{extra}</span>
<span className="sr-only">
{t('auto.components.cloudWorkspaces.moreMembers', '{{count}} more', { count: extra })}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">{summary}</TooltipContent>
</Tooltip>
)}
</span>
)
}
@@ -0,0 +1,94 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import { CloudWorkspaceConfirmDialog } from './CloudWorkspaceConfirmDialog'
import { DIRECTORY, presence, SELF_ID, workspace } from './cloud-workspaces.test-fixture'
import type { CloudWorkspacesApi } from '../../../../preload/api/cloud-workspaces-api'
const SHARED = workspace({
title: 'Checkout retries',
presence: [presence('user_grace', 'typing'), presence('user_linus')]
})
function stubGrants(userIds: string[]): void {
Object.assign(window.api.cloudWorkspaces, {
listGrants: (id: string) =>
Promise.resolve({
ok: true as const,
value: userIds.map((userId, index) => ({
id: `grt_${String(index).padStart(24, '0')}`,
workspaceId: id,
userId,
deviceKind: 'desktop' as const,
deviceLabel: 'MacBook Pro',
status: 'active' as const,
createdAt: Date.now(),
revokedAt: null
}))
})
} satisfies Partial<CloudWorkspacesApi>)
}
const frame = (node: React.ReactNode): React.JSX.Element => <div className="h-[260px]">{node}</div>
/** Names everyone who loses access: teammates here now plus active device grants. */
export const makePrivate = defineFixture({
width: 640,
setup: () => stubGrants(['user_mae']),
render: () =>
frame(
<CloudWorkspaceConfirmDialog
pending={{ kind: 'private', workspace: SHARED }}
directory={DIRECTORY}
selfId={SELF_ID}
canAdmin
onCancel={() => {}}
onConfirm={() => {}}
/>
)
})
export const makePrivateNobodyConnected = defineFixture({
width: 640,
setup: () => stubGrants([]),
render: () =>
frame(
<CloudWorkspaceConfirmDialog
pending={{ kind: 'private', workspace: workspace({ title: 'Docs search' }) }}
directory={DIRECTORY}
selfId={SELF_ID}
canAdmin
onCancel={() => {}}
onConfirm={() => {}}
/>
)
})
/** A member giving their own workspace away is told they lose management of it. */
export const reassignAsMember = defineFixture({
width: 640,
render: () =>
frame(
<CloudWorkspaceConfirmDialog
pending={{ kind: 'owner', workspace: SHARED, ownerUserId: 'user_grace' }}
directory={DIRECTORY}
selfId={SELF_ID}
canAdmin={false}
onCancel={() => {}}
onConfirm={() => {}}
/>
)
})
export const reassignAsAdmin = defineFixture({
width: 640,
render: () =>
frame(
<CloudWorkspaceConfirmDialog
pending={{ kind: 'owner', workspace: SHARED, ownerUserId: 'user_linus' }}
directory={DIRECTORY}
selfId={SELF_ID}
canAdmin
onCancel={() => {}}
onConfirm={() => {}}
/>
)
})
@@ -0,0 +1,137 @@
// Confirmation for the two row actions that take something away from people: making a shared
// workspace private (teammates' computers are disconnected) and handing ownership to someone else.
import React, { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
import { formatNameList, memberName, type MemberDirectory } from './cloud-workspace-format'
import { cloudWorkspaceTitle } from './open-cloud-workspace'
export type PendingConfirm =
| { kind: 'private'; workspace: CloudWorkspace }
| { kind: 'owner'; workspace: CloudWorkspace; ownerUserId: string }
/** Teammates who would lose access: here now, or holding an active device grant. */
function useConnectedTeammates(workspace: CloudWorkspace, selfId: string | null): string[] {
const here = workspace.presence.map((p) => p.userId)
const [granted, setGranted] = useState<string[]>([])
useEffect(() => {
let disposed = false
void window.api.cloudWorkspaces
.listGrants(workspace.id)
.then((result) => {
if (!disposed && result.ok) {
setGranted(result.value.filter((g) => g.status === 'active').map((g) => g.userId))
}
})
.catch(() => undefined)
return () => {
disposed = true
}
}, [workspace.id])
// The owner keeps access, and so do you (you are the one making the change).
return [...new Set([...here, ...granted])].filter(
(userId) => userId !== selfId && userId !== workspace.ownerUserId
)
}
function MakePrivateBody({
workspace,
directory,
selfId
}: {
workspace: CloudWorkspace
directory: MemberDirectory
selfId: string | null
}): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
const connected = useConnectedTeammates(workspace, selfId)
const names = formatNameList(
connected.map((userId) => memberName(directory, userId, selfId, t)),
intlLocale
)
return (
<DialogDescription>
{connected.length > 0
? t('auto.components.cloudWorkspaces.makePrivateDisconnects', '{{names}} will be disconnected.', {
names
})
: t(
'auto.components.cloudWorkspaces.makePrivateNobody',
'No teammates are connected right now. Only the owner will be able to open it.'
)}
</DialogDescription>
)
}
export function CloudWorkspaceConfirmDialog({
pending,
directory,
selfId,
canAdmin,
onCancel,
onConfirm
}: {
pending: PendingConfirm
directory: MemberDirectory
selfId: string | null
/** Team owner or admin: still manages every workspace after giving one away. */
canAdmin: boolean
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
const t = useTranslate()
const w = pending.workspace
const title =
pending.kind === 'private'
? t('auto.components.cloudWorkspaces.makePrivateTitle', 'Make {{title}} private?', {
title: cloudWorkspaceTitle(w, t)
})
: t('auto.components.cloudWorkspaces.reassignTitle', 'Make {{name}} the owner?', {
name: memberName(directory, pending.ownerUserId, selfId, t)
})
const losesManagement = pending.kind === 'owner' && w.ownerUserId === selfId && !canAdmin
return (
<Dialog open onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{pending.kind === 'private' ? (
<MakePrivateBody workspace={w} directory={directory} selfId={selfId} />
) : (
<DialogDescription>
{losesManagement
? t(
'auto.components.cloudWorkspaces.reassignLosesManagement',
"You won't be able to manage this workspace after this."
)
: t(
'auto.components.cloudWorkspaces.reassignBody',
'They can then share, archive or hand it on.'
)}
</DialogDescription>
)}
</DialogHeader>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t('auto.components.cloudWorkspaces.cancel', 'Cancel')}
</Button>
<Button type="button" onClick={onConfirm} autoFocus>
{pending.kind === 'private'
? t('auto.components.cloudWorkspaces.makePrivate', 'Make private')
: t('auto.components.cloudWorkspaces.makeOwner', 'Make owner')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -15,7 +15,7 @@ const ACTIONS: RowActions = {
open: () => {},
copyLink: () => {},
copyWorkspaceLink: () => {},
follow: () => {},
goToChat: () => {},
patch: () => {}
}
const SECTIONS = ['Payments', 'Platform']
@@ -99,7 +99,7 @@ export const statuses = defineFixture({
)
})
/** Share/private, copy links, follow, move to section and reassign live in the row menu. */
/** Share/private, copy links, go to a teammate's chat, move to section and reassign live in the row menu. */
export const menuOpen = defineFixture({
width: WIDTH,
render: () => (
@@ -140,7 +140,7 @@ export const followSubmenu = defineFixture({
<Row />
</div>
),
interact: (frame) => openSubmenu(frame, 'Follow')
interact: (frame) => openSubmenu(frame, "Go to a teammate's chat")
})
/** A private workspace offers "Share with team" instead of "Make private". */
@@ -1,4 +1,4 @@
import React, { useId, useState } from 'react'
import React, { useEffect, useId, useRef, useState } from 'react'
import { GitBranch, Loader2, Lock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -8,6 +8,8 @@ import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { keybindingMatchesAction } from '../../../../shared/keybindings/matching'
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
import { CloudStateDot } from '../orca-cloud/CloudStateDot'
import type { CloudDotState } from '@/lib/orca-cloud/orca-cloud-workspace-state'
import { MemberAvatar, PresenceStack } from './CloudMemberAvatars'
import { CloudWorkspaceRowMenu, type RowActions } from './CloudWorkspaceRowMenu'
import {
@@ -23,15 +25,26 @@ import {
} from './cloud-workspace-format'
import { cloudWorkspaceTitle } from './open-cloud-workspace'
const STATUS_DOT: Record<CloudWorkspace['status'], string> = {
running: 'bg-status-success',
creating: 'bg-status-warning motion-safe:animate-pulse',
resuming: 'bg-status-warning motion-safe:animate-pulse',
suspending: 'bg-muted-foreground/60 motion-safe:animate-pulse',
suspended: 'bg-muted-foreground/40',
destroying: 'bg-destructive/70',
destroyed: 'bg-destructive/70',
failed: 'bg-destructive'
/** The shared Orca Cloud dot vocabulary (shape and tone), same as the sidebar and panel. */
function dotState(w: CloudWorkspace): CloudDotState {
if (w.archivedAt !== null) {
return 'archived'
}
switch (w.status) {
case 'running':
return 'running'
case 'creating':
case 'resuming':
return 'waking'
case 'suspending':
case 'suspended':
return 'sleeping'
case 'failed':
return 'failed'
case 'destroying':
case 'destroyed':
return 'unresponsive'
}
}
/** Glossary lifecycle states only: a sandbox going to sleep already reads as Asleep. */
@@ -86,10 +99,18 @@ export function CloudWorkspaceRow({
const descriptionId = useId()
const [menuOpen, setMenuOpen] = useState(false)
const status = statusLabel(w, t)
const showStatus = w.archivedAt !== null || w.status !== 'running'
const archived = w.archivedAt !== null
const showStatus = archived || w.status !== 'running'
const canManage = w.access === 'owner' || canAdmin
const title = cloudWorkspaceTitle(w, t)
const ownerName = memberName(directory, w.ownerUserId, selfId, t)
const rowRef = useRef<HTMLLIElement>(null)
// A link brought this row up: make sure it is on screen (the tint fades on its own).
useEffect(() => {
if (highlighted) {
rowRef.current?.scrollIntoView({ block: 'nearest' })
}
}, [highlighted])
const canOpen = w.access !== 'none'
const cost = formatUsdMicros(w.cost.totalUsdMicros, intlLocale)
const lastActive = formatRelative(w.lastActivityAt, now, t, intlLocale)
@@ -123,6 +144,7 @@ export function CloudWorkspaceRow({
return (
<li
ref={rowRef}
data-cloud-workspace-row=""
onKeyDown={(event) => {
// Menu items render in a portal but still bubble through React; only this row's own DOM counts.
@@ -142,38 +164,72 @@ export function CloudWorkspaceRow({
}
}}
className={cn(
'group relative flex items-center gap-3 rounded-lg pr-2 transition-colors hover:bg-accent',
menuOpen && 'bg-accent',
highlighted && 'ring-2 ring-ring/60',
w.archivedAt !== null && 'opacity-70'
'group relative isolate flex items-center gap-3 rounded-lg pr-2 transition-colors hover:bg-accent',
menuOpen && 'bg-accent'
)}
>
{/* Opened from a link: an accent tint that fades out (instantly under reduced motion). */}
<span
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-lg bg-accent opacity-0 motion-safe:transition-opacity motion-safe:duration-[2s]',
highlighted && 'opacity-100'
)}
/>
<button
type="button"
disabled={!canOpen}
onClick={() => actions.open(w)}
aria-label={t(
'auto.components.cloudWorkspaces.rowLabel',
'{{value0}}, {{value1}}, owned by {{value2}}',
{ value0: title, value1: status, value2: ownerName }
)}
aria-label={
w.ownerUserId === selfId
? t('auto.components.cloudWorkspaces.rowLabelOwnedByYou', '{{title}}, {{status}}, owned by you', {
title,
status
})
: t('auto.components.cloudWorkspaces.rowLabel', '{{value0}}, {{value1}}, owned by {{value2}}', {
value0: title,
value1: status,
value2: ownerName
})
}
aria-describedby={descriptionId}
className="flex min-w-0 flex-1 cursor-default items-center gap-3 rounded-lg py-2 pl-3 text-left outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-focus-ring"
>
<span
role="img"
aria-label={status}
className={cn('size-2 shrink-0 rounded-full', STATUS_DOT[w.status])}
/>
className={cn('inline-flex shrink-0', archived && 'opacity-60')}
>
<CloudStateDot state={dotState(w)} size="md" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground">{title}</span>
{/* Archived rows dim only the title and dot, so cost, time and actions stay legible. */}
<span
className={cn(
'truncate text-sm font-medium',
archived ? 'text-muted-foreground' : 'text-foreground'
)}
>
{title}
</span>
{w.visibility === 'private' && (
<Lock
role="img"
className="size-3 shrink-0 text-muted-foreground"
aria-label={t('auto.components.cloudWorkspaces.private', 'Private')}
/>
<Tooltip>
<TooltipTrigger asChild>
<Lock
role="img"
className="size-3 shrink-0 text-muted-foreground"
aria-label={t('auto.components.cloudWorkspaces.private', 'Private')}
/>
</TooltipTrigger>
<TooltipContent side="top">
{w.ownerUserId === selfId
? t('auto.components.cloudWorkspaces.privateTooltipYou', 'Private — only you can see it')
: t('auto.components.cloudWorkspaces.privateTooltip', 'Private — only {{name}} can open it', {
name: ownerName
})}
</TooltipContent>
</Tooltip>
)}
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
@@ -182,7 +238,15 @@ export function CloudWorkspaceRow({
{showStatus && (
<>
<span aria-hidden>·</span>
<span className={cn('shrink-0', w.status === 'failed' && 'text-destructive')}>
<span
className={cn(
'shrink-0',
!archived && w.status === 'failed' && 'text-destructive',
!archived &&
(w.status === 'destroying' || w.status === 'destroyed') &&
'text-status-warning-text'
)}
>
{status}
</span>
</>
@@ -207,7 +271,7 @@ export function CloudWorkspaceRow({
presence={w.presence}
directory={directory}
selfId={selfId}
onFollow={canOpen ? (member) => actions.follow(w, member) : undefined}
onGoToChat={canOpen ? (member) => actions.goToChat(w, member) : undefined}
/>
<Tooltip>
<TooltipTrigger asChild>
@@ -3,7 +3,6 @@ import {
Archive,
ArchiveRestore,
ArrowUpRight,
Eye,
FolderInput,
FolderPlus,
Link2,
@@ -42,8 +41,8 @@ export type RowActions = {
/** The link to the chat you have in front when this workspace is in front, else the workspace. */
copyLink: (w: CloudWorkspace) => void
copyWorkspaceLink: (w: CloudWorkspace) => void
/** Open the workspace on the chat this teammate has in front. */
follow: (w: CloudWorkspace, member: CloudWorkspacePresence) => void
/** One-time jump: open the workspace on the chat this teammate has in front. */
goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void
patch: (w: CloudWorkspace, patch: CloudWorkspacePatch) => void
}
@@ -82,7 +81,7 @@ export function CloudWorkspaceRowMenu({
const t = useTranslate()
const keybindings = useAppStore((state) => state.keybindings)
const copyShortcut = formatOptionalPrimaryShortcutLabel('cloudWorkspace.copyLink', keybindings)
const followable = canOpen
const teammatesHere = canOpen
? presentMembers(w.presence, selfId).filter((p) => p.userId !== selfId)
: []
// Focus returns to ⋯ on close; after a mouse open that would leave a ring nobody asked for.
@@ -133,15 +132,15 @@ export function CloudWorkspaceRowMenu({
<Link2 />
{t('auto.components.cloudWorkspaces.copyWorkspaceLink', 'Copy workspace link')}
</DropdownMenuItem>
{followable.length > 0 && (
{teammatesHere.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Eye />
{t('auto.components.cloudWorkspaces.follow', 'Follow')}
<ArrowUpRight />
{t('auto.components.cloudWorkspaces.goToTeammateChat', "Go to a teammate's chat")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56" collisionPadding={SUBMENU_COLLISION_PADDING}>
{followable.map((member) => (
<DropdownMenuItem key={member.userId} onSelect={() => actions.follow(w, member)}>
{teammatesHere.map((member) => (
<DropdownMenuItem key={member.userId} onSelect={() => actions.goToChat(w, member)}>
<MemberAvatar
userId={member.userId}
directory={directory}
@@ -149,7 +148,7 @@ export function CloudWorkspaceRowMenu({
size="sm"
/>
<span className="truncate">
{t('auto.components.cloudWorkspaces.followMember', 'Follow {{value0}}', {
{t('auto.components.cloudWorkspaces.goToChat', "Go to {{value0}}'s chat", {
value0: memberName(directory, member.userId, selfId, t)
})}
</span>
@@ -16,12 +16,13 @@ import { CloudWorkspaceRow } from './CloudWorkspaceRow'
import { NEW_SECTION } from './CloudWorkspaceRowMenu'
import { ANY, CloudWorkspacesToolbar, type CloudWorkspacesFilters } from './CloudWorkspacesToolbar'
import { EmptyState, ErrorState, NewSectionDialog, SkeletonRows } from './CloudWorkspacesStates'
import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog'
import { formatUsdMicros, type MemberDirectory } from './cloud-workspace-format'
import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender'
import { describeError } from './cloud-workspace-errors'
import { focusCloudChat } from './cloud-chat-focus'
import { frontCloudChat } from './cloud-workspace-links'
import { copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace'
import { cloudWorkspaceTitle, copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace'
import { useCloudWorkspaces, useCloudWorkspacesAvailable } from './use-cloud-workspaces'
const INITIAL_FILTERS: CloudWorkspacesFilters = {
@@ -70,6 +71,8 @@ export function CloudWorkspacesHome({
const [opening, setOpening] = useState<string | null>(null)
const [now, setNow] = useState(() => Date.now())
const [sectionFor, setSectionFor] = useState<CloudWorkspace | null>(null)
const [confirm, setConfirm] = useState<PendingConfirm | null>(null)
const signIn = useAppStore((s) => s.connectCurrentOrcaProfile)
const { directory, canAdmin } = useMemberDirectory(orgId)
const scope = orgId ? filters.scope : 'mine'
const { state, stale, reading, refresh, replace } = useCloudWorkspaces(
@@ -113,15 +116,18 @@ export function CloudWorkspacesHome({
// Owner filtering only exists in the team view; a repo that left the list stops filtering.
const by = scope === 'team' ? filters.by : ANY
const repo = repos.includes(filters.repo) ? filters.repo : ANY
// A row a link opened always shows, even when the owner/repo filters would hide it.
const visible = useMemo(
() =>
all.filter(
(w) =>
(by === ANY || w.ownerUserId === by || w.createdBy === by) &&
(repo === ANY || w.repo?.url === repo)
w.id === highlightId ||
((by === ANY || w.ownerUserId === by || w.createdBy === by) &&
(repo === ANY || w.repo?.url === repo))
),
[all, by, repo]
[all, by, repo, highlightId]
)
const highlighted = highlightId ? visible.find((w) => w.id === highlightId) : undefined
const groups = useMemo(() => {
const out = new Map<string, CloudWorkspace[]>()
for (const w of visible) {
@@ -145,7 +151,7 @@ export function CloudWorkspacesHome({
)
const totalCost = visible.reduce((sum, w) => sum + w.cost.totalUsdMicros, 0)
/** Opens the workspace, then (when following a teammate) the chat they have in front. */
/** Opens the workspace, then (going to a teammate's chat) the chat they have in front. */
const open = useCallback(async (w: CloudWorkspace, chatId?: string | null) => {
setOpening(w.id)
try {
@@ -161,11 +167,20 @@ export function CloudWorkspacesHome({
}, [])
const patch = useCallback(
async (w: CloudWorkspace, change: CloudWorkspacePatch) => {
async (w: CloudWorkspace, change: CloudWorkspacePatch, confirmed = false) => {
if (change.section === NEW_SECTION) {
setSectionFor(w)
return
}
// Making private and handing over ownership take access away; both ask first.
if (!confirmed && change.visibility === 'private') {
setConfirm({ kind: 'private', workspace: w })
return
}
if (!confirmed && change.ownerUserId) {
setConfirm({ kind: 'owner', workspace: w, ownerUserId: change.ownerUserId })
return
}
const result = await window.api.cloudWorkspaces.patch(w.id, change)
if (!result.ok) {
toast.error(describeError(result.error, result.reason, t))
@@ -209,7 +224,7 @@ export function CloudWorkspacesHome({
void copyCloudWorkspaceLink(w.id, front?.sandboxId === w.id ? front.chatId : null)
},
copyWorkspaceLink: (w: CloudWorkspace) => void copyCloudWorkspaceLink(w.id),
follow: (w: CloudWorkspace, member: CloudWorkspacePresence) => void open(w, member.chatId),
goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void open(w, member.chatId),
patch: (w: CloudWorkspace, change: CloudWorkspacePatch) => void patch(w, change)
}),
[open, patch]
@@ -254,9 +269,13 @@ export function CloudWorkspacesHome({
{state.status === 'loading' && <SkeletonRows />}
{state.status === 'error' && (
<ErrorState
message={describeError(state.error, state.reason, t)}
error={state.error}
reason={state.reason}
retrying={reading}
onRetry={refresh}
onSignIn={() => {
void signIn().then(refresh)
}}
/>
)}
{state.status === 'ready' && visible.length === 0 && (
@@ -280,7 +299,7 @@ export function CloudWorkspacesHome({
>
{section || t('auto.components.cloudWorkspaces.noSection', 'No section')}
{/* The bare number is visual; screen readers get the counted phrase. */}
<span aria-hidden className="ml-1.5 tabular-nums text-muted-foreground/70">
<span aria-hidden className="ml-1.5 tabular-nums text-muted-foreground">
{rows.length}
</span>{' '}
<span className="sr-only">
@@ -316,6 +335,33 @@ export function CloudWorkspacesHome({
})}
</div>
{/* Polite, so opening from a link is announced without stealing focus. */}
<div aria-live="polite" className="sr-only">
{highlighted
? t('auto.components.cloudWorkspaces.openedFromLink', 'Opened from link: {{title}}', {
title: cloudWorkspaceTitle(highlighted, t)
})
: null}
</div>
{confirm && (
<CloudWorkspaceConfirmDialog
pending={confirm}
directory={directory}
selfId={selfId}
canAdmin={canAdmin}
onCancel={() => setConfirm(null)}
onConfirm={() => {
const change: CloudWorkspacePatch =
confirm.kind === 'private'
? { visibility: 'private' }
: { ownerUserId: confirm.ownerUserId }
setConfirm(null)
void patch(confirm.workspace, change, true)
}}
/>
)}
{sectionFor && (
<NewSectionDialog
onCancel={() => setSectionFor(null)}
@@ -1,6 +1,5 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import { EmptyState, ErrorState, SkeletonRows } from './CloudWorkspacesStates'
import { describeError } from './cloud-workspace-errors'
const WIDTH = 720
@@ -44,17 +43,19 @@ export const errors = defineFixture({
render: () => (
<div className="flex flex-col gap-3">
<Card>
<ErrorState message={describeError('unavailable')} retrying={false} onRetry={() => {}} />
<ErrorState error="unavailable" retrying={false} onRetry={() => {}} onSignIn={() => {}} />
</Card>
<Card>
<ErrorState
message={describeError('entitlement_denied', 'concurrency_limit')}
error="entitlement_denied"
reason="concurrency_limit"
onSignIn={() => {}}
retrying
onRetry={() => {}}
/>
</Card>
<Card>
<ErrorState message={describeError('unauthorized')} retrying={false} onRetry={() => {}} />
<ErrorState error="unauthorized" retrying={false} onRetry={() => {}} onSignIn={() => {}} />
</Card>
</div>
)
@@ -12,24 +12,35 @@ import { InlineError } from '@/components/ui/inline-error'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslate } from '@/i18n/use-translate'
import { describeInlineError } from './cloud-workspace-errors'
/** The list failed to load. An expired session offers Sign in; anything else offers Retry. */
export function ErrorState({
message,
error,
reason,
retrying,
onRetry
onRetry,
onSignIn
}: {
message: string
error: string
reason?: string
retrying: boolean
onRetry: () => void
onSignIn: () => void
}): React.JSX.Element {
const t = useTranslate()
const signIn = error === 'unauthorized'
return (
<InlineError
className="m-1"
title={message}
retryLabel={t('auto.components.cloudWorkspaces.retry', 'Retry')}
title={describeInlineError(error, reason, t)}
retryLabel={
signIn
? t('auto.components.cloudWorkspaces.signIn', 'Sign in')
: t('auto.components.cloudWorkspaces.retry', 'Retry')
}
retrying={retrying}
onRetry={onRetry}
onRetry={signIn ? onSignIn : onRetry}
/>
)
}
@@ -89,10 +100,10 @@ export function NewSectionDialog({
export function SkeletonRows(): React.JSX.Element {
const t = useTranslate()
return (
<div
aria-busy="true"
aria-label={t('auto.components.cloudWorkspaces.loading', 'Loading cloud workspaces')}
>
<div role="status" aria-busy="true">
<span className="sr-only">
{t('auto.components.cloudWorkspaces.loading', 'Loading cloud workspaces')}
</span>
{[0, 1, 2].map((i) => (
// Same height and columns as a real row (status, title + meta, cost, last active, ⋯).
<div key={i} className="flex h-14 items-center gap-3 pl-3 pr-2">
@@ -54,3 +54,38 @@ export function describeError(error: string, reason?: string, t: Translate = tra
'Something went wrong. Nothing was changed. Try again.'
)
}
/**
* The same messages for an inline error that already carries the action button (Retry or Sign
* in), so the body does not repeat "Try again." or "Sign in again.".
*/
export function describeInlineError(
error: string,
reason?: string,
t: Translate = translate
): string {
if (error === 'unauthorized') {
return t('auto.components.cloudWorkspaces.errorUnauthorizedInline', 'Your Orca Cloud session expired.')
}
if (error === 'timeout' || error === 'unavailable' || error === 'provider_unavailable') {
return t(
'auto.components.cloudWorkspaces.errorUnreachableInline',
"Orca Cloud isn't reachable right now. Nothing was changed."
)
}
if (error === 'conflict' && reason !== 'too_many_devices') {
return t(
'auto.components.cloudWorkspaces.errorConflictInline',
'This workspace is still starting or going to sleep. Nothing was changed.'
)
}
if (
error === 'not_found' ||
error === 'entitlement_denied' ||
error === 'forbidden' ||
error === 'conflict'
) {
return describeError(error, reason, t)
}
return t('auto.components.cloudWorkspaces.errorUnknownInline', 'Something went wrong. Nothing was changed.')
}
@@ -148,10 +148,21 @@ export function presenceSummary(
intlLocale: string = getIntlLocale()
): string {
const clauses = members.map((p) => {
// "You" takes its own verb form; it is never slotted into "{{name}} is …".
if (p.userId === selfId) {
return p.state === 'typing'
? t('auto.components.cloudWorkspaces.youAreTyping', 'You are typing')
: t('auto.components.cloudWorkspaces.youAreViewing', 'You are viewing')
}
const value0 = memberName(directory, p.userId, selfId, t)
return p.state === 'typing'
? t('auto.components.cloudWorkspaces.memberTyping', '{{value0}} is typing', { value0 })
: t('auto.components.cloudWorkspaces.memberViewing', '{{value0}} is viewing', { value0 })
})
return new Intl.ListFormat(intlLocale, { style: 'long', type: 'conjunction' }).format(clauses)
return formatNameList(clauses, intlLocale)
}
/** "Grace, Linus and Mae" in the UI locale, never hand-joined. */
export function formatNameList(names: string[], intlLocale: string = getIntlLocale()): string {
return new Intl.ListFormat(intlLocale, { style: 'long', type: 'conjunction' }).format(names)
}
+22 -3
View File
@@ -20190,17 +20190,21 @@
"environmentName": "{{value0}} (cloud)",
"errorConcurrencyLimit": "You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again.",
"errorConflict": "This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.",
"errorConflictInline": "This workspace is still starting or going to sleep. Nothing was changed.",
"errorEntitlementDenied": "Your team's plan can't start this workspace right now. Your files are safe. Ask an owner or admin to check the plan.",
"errorForbidden": "Only the workspace owner or a team admin can do that. Ask an owner or admin to make the change.",
"errorNotFound": "This workspace is private, archived or no longer exists.",
"errorTooManyDevices": "Too many of your computers are connected to this workspace. Disconnect one, then try again.",
"errorUnauthorized": "Your Orca Cloud session expired. Sign in again.",
"errorUnauthorizedInline": "Your Orca Cloud session expired.",
"errorUnknown": "Something went wrong. Nothing was changed. Try again.",
"errorUnknownInline": "Something went wrong. Nothing was changed.",
"errorUnreachable": "Orca Cloud isn't reachable right now. Nothing was changed. Try again.",
"errorUnreachableInline": "Orca Cloud isn't reachable right now. Nothing was changed.",
"filterByOwner": "Filter by owner",
"filterByRepository": "Filter by repository",
"follow": "Follow",
"followMember": "Follow {{value0}}",
"goToChat": "Go to {{value0}}'s chat",
"goToTeammateChat": "Go to a teammate's chat",
"heading": "Cloud workspaces",
"hoursAgo": "{{value0}}h ago",
"inAnyRepo": "In any repo",
@@ -20210,12 +20214,17 @@
"listMeta_one": "{{count}} workspace · {{cost}}",
"listMeta_other": "{{count}} workspaces · {{cost}}",
"loading": "Loading cloud workspaces",
"makeOwner": "Make owner",
"makePrivate": "Make private",
"makePrivateDisconnects": "{{names}} will be disconnected.",
"makePrivateNobody": "No teammates are connected right now. Only the owner will be able to open it.",
"makePrivateTitle": "Make {{title}} private?",
"memberTyping": "{{value0}} is typing",
"memberViewing": "{{value0}} is viewing",
"memberYou": "{{value0}} (you)",
"minutesAgo": "{{value0}}m ago",
"modelTokensCost": "Model tokens {{value0}}",
"moreMembers": "{{count}} more",
"move": "Move",
"moveToSection": "Move to section",
"newSection": "New section",
@@ -20225,10 +20234,16 @@
"nowPrivate": "Now private. Teammates’ devices were disconnected.",
"open": "Open",
"openFailed": "Couldn't open the workspace. Your files are safe. Try again.",
"openedFromLink": "Opened from link: {{title}}",
"opening": "Opening",
"ownerChanged": "Owner changed",
"private": "Private",
"privateTooltip": "Private — only {{name}} can open it",
"privateTooltipYou": "Private — only you can see it",
"reassignBody": "They can then share, archive or hand it on.",
"reassignLosesManagement": "You won't be able to manage this workspace after this.",
"reassignOwner": "Reassign owner",
"reassignTitle": "Make {{name}} the owner?",
"refresh": "Refresh cloud workspaces",
"restored": "Restored",
"retry": "Retry",
@@ -20236,6 +20251,7 @@
"rowDescription": "Cost {{cost}}. Last active {{lastActive}}. {{visibility}}.",
"rowDescriptionWithPresence": "Cost {{cost}}. Last active {{lastActive}}. {{visibility}}. {{presence}}.",
"rowLabel": "{{value0}}, {{value1}}, owned by {{value2}}",
"rowLabelOwnedByYou": "{{title}}, {{status}}, owned by you",
"sandboxHoursCost_one": "{{hours}} sandbox hour × {{rate}}/hour = {{cost}}",
"sandboxHoursCost_other": "{{hours}} sandbox hours × {{rate}}/hour = {{cost}}",
"scopeMine": "Mine",
@@ -20249,6 +20265,7 @@
"sharedWithTeam": "Shared with team",
"sharedWithYourTeam": "Shared with your team",
"showArchived": "Show archived",
"signIn": "Sign in",
"statusArchived": "Archived",
"statusAsleep": "Asleep",
"statusCouldntStart": "Couldn't start",
@@ -20261,7 +20278,9 @@
"whoseWorkspaces": "Whose workspaces",
"workspaceFallback": "workspace",
"workspaceLinkCopied": "Workspace link copied",
"you": "You"
"you": "You",
"youAreTyping": "You are typing",
"youAreViewing": "You are viewing"
},
"orcaCloud": {
"QueuedPrompt": {