From 8d07fd6b0989f253deac62dcbdec59cdb07028f4 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 07:44:08 -0800 Subject: [PATCH] =?UTF-8?q?fix(cloud):=20W10=20round=206=20=E2=80=94=20sha?= =?UTF-8?q?red=20CloudStateDot,=20'Go=20to=20{name}'s=20chat',=20confirms?= =?UTF-8?q?=20for=20make=20private=20/=20reassign=20owner,=20grammar-aware?= =?UTF-8?q?=20'you',=20sign-in=20action,=20presence=20legibility,=20link?= =?UTF-8?q?=20highlight,=20archived=20dimming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudMemberAvatars.fixture.tsx | 14 +- .../cloud-workspaces/CloudMemberAvatars.tsx | 103 +++++++------ .../CloudWorkspaceConfirmDialog.fixture.tsx | 94 ++++++++++++ .../CloudWorkspaceConfirmDialog.tsx | 137 ++++++++++++++++++ .../CloudWorkspaceRow.fixture.tsx | 6 +- .../cloud-workspaces/CloudWorkspaceRow.tsx | 124 ++++++++++++---- .../CloudWorkspaceRowMenu.tsx | 19 ++- .../cloud-workspaces/CloudWorkspacesHome.tsx | 64 ++++++-- .../CloudWorkspacesStates.fixture.tsx | 9 +- .../CloudWorkspacesStates.tsx | 31 ++-- .../cloud-workspace-errors.ts | 35 +++++ .../cloud-workspace-format.ts | 13 +- src/renderer/src/i18n/locales/en.json | 25 +++- 13 files changed, 554 insertions(+), 120 deletions(-) create mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx diff --git a/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.fixture.tsx index 3e1d0cea295..92afb00a6ca 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.fixture.tsx @@ -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={() => {}} /> @@ -35,7 +35,7 @@ export const presenceStacks = defineFixture({ presence={[presence('user_grace', 'typing'), presence('user_linus')]} directory={DIRECTORY} selfId={SELF_ID} - onFollow={() => {}} + onGoToChat={() => {}} /> @@ -43,7 +43,7 @@ export const presenceStacks = defineFixture({ presence={[presence(SELF_ID), presence('user_mae', 'typing')]} directory={DIRECTORY} selfId={SELF_ID} - onFollow={() => {}} + onGoToChat={() => {}} /> @@ -53,14 +53,14 @@ export const presenceStacks = defineFixture({ )} directory={DIRECTORY} selfId={SELF_ID} - onFollow={() => {}} + onGoToChat={() => {}} /> ) }) -/** 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={() => {}} /> ), diff --git a/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.tsx b/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.tsx index c3496e4cbc2..0e40ada36d3 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudMemberAvatars.tsx @@ -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 ( - - - - {/* 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 = ( - <> - - {p.state === 'typing' && ( - - )} - - ) - return onFollow && p.userId !== selfId ? ( + + {/* 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 = ( + <> + + {p.state === 'typing' && ( + + )} + + ) + if (!onGoToChat || p.userId === selfId) { + return ( + + + + {avatar} + + + {status} + + ) + } + const goTo = t('auto.components.cloudWorkspaces.goToChat', "Go to {{value0}}'s chat", { + value0: memberName(directory, p.userId, selfId, t) + }) + return ( + + - ) : ( - - {avatar} + + + + + {goTo} - ) - })} - {extra > 0 && ( - - +{extra} - - )} + {status} + + + ) + })} + {extra > 0 && ( + + +{extra} + + {t('auto.components.cloudWorkspaces.moreMembers', '{{count}} more', { count: extra })} + - - {summary} - + )} + ) } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx new file mode 100644 index 00000000000..f2a64ecea27 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.fixture.tsx @@ -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) +} + +const frame = (node: React.ReactNode): React.JSX.Element =>
{node}
+ +/** Names everyone who loses access: teammates here now plus active device grants. */ +export const makePrivate = defineFixture({ + width: 640, + setup: () => stubGrants(['user_mae']), + render: () => + frame( + {}} + onConfirm={() => {}} + /> + ) +}) + +export const makePrivateNobodyConnected = defineFixture({ + width: 640, + setup: () => stubGrants([]), + render: () => + frame( + {}} + onConfirm={() => {}} + /> + ) +}) + +/** A member giving their own workspace away is told they lose management of it. */ +export const reassignAsMember = defineFixture({ + width: 640, + render: () => + frame( + {}} + onConfirm={() => {}} + /> + ) +}) + +export const reassignAsAdmin = defineFixture({ + width: 640, + render: () => + frame( + {}} + onConfirm={() => {}} + /> + ) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx new file mode 100644 index 00000000000..ea161557f9e --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceConfirmDialog.tsx @@ -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([]) + 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 ( + + {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.' + )} + + ) +} + +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 ( + !next && onCancel()}> + + + {title} + {pending.kind === 'private' ? ( + + ) : ( + + {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.' + )} + + )} + + + + + + + + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx index 5670e9f9a05..b85b1402eb1 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx @@ -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({ ), - 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". */ diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx index 73b3a50fc28..df748c135ae 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx @@ -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 = { - 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(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 (
  • { // 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). */} +