mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user