feat(cloud): PARITY-FOLLOW phase 2: Follow, followers and the Following section (#665)

* wip(cloud): PARITY-FOLLOW design fixtures (top-bar followers, sidebar Team and Following)

* feat(cloud): PARITY-FOLLOW phase 1: sidebar Team list and the top bar's people (creator and who is here now)

* test(cloud): PARITY-FOLLOW fixtures name the workspace a teammate is in

* fix(cloud): PARITY-FOLLOW fixture typecheck

* feat(cloud): PARITY-FOLLOW follow endpoint: workspace_follows (0055), PUT/DELETE /v1/team/workspaces/:id/follow, followers in workspace views

* fix(cloud): PARITY-FOLLOW lint: the hover action's wrapper owns its visibility; no Button restyles; no side effect in a state updater

* feat(cloud): PARITY-FOLLOW phase 2: Follow in the top bar, followers in the stack, Following section in the sidebar

* test(cloud): PARITY-FOLLOW no real-time wait in the live-read test
This commit is contained in:
Neil
2026-11-13 02:59:54 -08:00
committed by GitHub
parent 5942f40782
commit 7a07d9cafb
14 changed files with 233 additions and 25 deletions
@@ -14,6 +14,7 @@ import {
CloudWorkspaceListResponseSchema,
CloudWorkspacePresenceResponseSchema,
CloudWorkspaceResponseSchema,
CloudWorkspaceFollowSchema,
OrgPromptsSchema,
type CloudWorkspaceLinkTarget,
type CloudWorkspaceListQuery,
@@ -157,6 +158,9 @@ export function createCloudWorkspacesClient(options: CloudWorkspacesClientOption
async patch(id: string, patch: CloudWorkspacePatch) {
return call('PATCH', ws(id), CloudWorkspaceResponseSchema.extend({}).passthrough(), patch)
},
async follow(id: string, follow: boolean) {
return call(follow ? 'PUT' : 'DELETE', `${ws(id)}/follow`, CloudWorkspaceFollowSchema)
},
async noteSent(id: string) {
await call('POST', `${ws(id)}/sent`, NoteSentSchema, {})
},
@@ -213,6 +213,7 @@ export function createCloudWorkspacesService(deps: {
return reader.read(id)
}),
get: (id: string) => wrap(() => client.get(id)),
follow: (id: string, follow: boolean) => wrap(() => client.follow(id, follow)),
patch: (id: string, patch: CloudWorkspacePatch) =>
wrap(async () => (await client.patch(id, patch)).workspace),
resolveLink: (target: CloudWorkspaceLinkTarget, chatId?: string | null) =>
+9
View File
@@ -193,6 +193,15 @@ export function registerCloudWorkspacesHandlers(
? s.patch(parsed.data.id, parsed.data.patch)
: INVALID
})
handle('cloudWorkspaces:follow', async (args) => {
const parsed = z.object({ id: Id, follow: z.boolean() }).safeParse(args)
const s = await getService()
return !s
? NOT_CONFIGURED
: parsed.success
? s.follow(parsed.data.id, parsed.data.follow)
: INVALID
})
handle('cloudWorkspaces:resolveLink', async (args) => {
const parsed = z
.object({ target: LinkTarget, chatId: ChatId.nullable().optional() })
+3
View File
@@ -1,5 +1,6 @@
import type {
CloudWorkspace,
CloudWorkspaceFollow,
CloudWorkspaceGrant,
CloudOrgSlug,
CloudWorkspaceLinkTarget,
@@ -37,6 +38,8 @@ export type CloudWorkspacesApi = {
list: (query: CloudWorkspaceListQuery) => Promise<CloudWorkspaceListResult>
get: (id: string) => Promise<CloudWorkspacesResult<CloudWorkspace>>
patch: (id: string, patch: CloudWorkspacePatch) => Promise<CloudWorkspacesResult<CloudWorkspace>>
/** Follow or unfollow a team workspace (PARITY-FOLLOW): the caller's state and its followers. */
follow: (id: string, follow: boolean) => Promise<CloudWorkspacesResult<CloudWorkspaceFollow>>
/** Resolves either link form; a slug link for another org fails with reason `other_org` and its `orgId`. */
resolveLink: (
target: CloudWorkspaceLinkTarget,
@@ -6,6 +6,7 @@ export const cloudWorkspacesApi = {
list: (query) => ipcRenderer.invoke('cloudWorkspaces:list', query),
get: (id) => ipcRenderer.invoke('cloudWorkspaces:get', { id }),
patch: (id, patch) => ipcRenderer.invoke('cloudWorkspaces:patch', { id, patch }),
follow: (id, follow) => ipcRenderer.invoke('cloudWorkspaces:follow', { id, follow }),
resolveLink: (target, chatId) =>
ipcRenderer.invoke('cloudWorkspaces:resolveLink', { target, chatId }),
openGrant: (id) => ipcRenderer.invoke('cloudWorkspaces:openGrant', { id }),
@@ -1,26 +1,42 @@
// PARITY-FOLLOW: the people on the cloud workspace in front, beside its W-123 chip. Phase 1 shows
// its creator and who is here now (the team list's live presence); Follow arrives with the follow
// endpoint. Nothing for a local workspace, a personal account, or a workspace only you are on.
import React from 'react'
// PARITY-FOLLOW: the people on the cloud workspace in front, beside its W-123 chip: its creator,
// its followers and who is here now, then Follow. Nothing for a local workspace, a personal
// account, or a workspace only you are on and nobody else could follow.
import React, { useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import { useOrcaAccountKey } from '@/hooks/use-orca-account-key'
import { openTeammateProfile } from '@/components/teammates/teammate-profile-store'
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
import { sandboxIdForWorktree, useCloudWorkspaceIdentities } from './cloud-workspace-identity'
import { CloudWorkspaceFollowers, workspaceFollowerEntries } from './CloudWorkspaceFollowers'
import { useTeamWorkspacesLive } from './team-workspaces-live'
import { setCloudWorkspaceFollow, useTeamWorkspacesLive } from './team-workspaces-live'
import { useMemberDirectory } from './use-cloud-workspace-row-actions'
import { useCloudWorkspacesAvailable } from './use-cloud-workspaces'
/**
* Follow is offered on a teammate's team workspace; never on your own (it is yours already), and not
* by a control plane that predates follows (`following` absent).
*/
export function followOffer(workspace: CloudWorkspace, selfId: string | null): boolean | null {
if (workspace.following === undefined || workspace.createdBy === selfId) {
return null
}
return workspace.following
}
export function CloudWorkspacePeople({
worktreeId
}: {
worktreeId: string
}): React.JSX.Element | null {
const t = useTranslate()
const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud)
const hostId = useAppStore((s) => s.activeWorkspaceExecutionHostId)
const account = useOrcaAccountKey()
const available = useCloudWorkspacesAvailable()
const identities = useCloudWorkspaceIdentities()
const [busy, setBusy] = useState(false)
const orgId = cloud?.activeOrgId ?? null
const selfId = cloud?.userId ?? null
const sandboxId = sandboxIdForWorktree({ id: worktreeId, hostId }, identities)
@@ -30,21 +46,38 @@ export function CloudWorkspacePeople({
if (!workspace) {
return null
}
const following = followOffer(workspace, selfId)
const entries = workspaceFollowerEntries({
creatorId: workspace.createdBy,
followers: [],
followers: workspace.followers ?? [],
presence: workspace.presence
})
// Only you, on your own workspace: nothing to show.
if (entries.every((e) => e.userId === selfId)) {
if (following === null && entries.every((e) => e.userId === selfId)) {
return null
}
const toggle = (): void => {
const next = !following
setBusy(true)
void setCloudWorkspaceFollow(workspace.id, next).then((ok) => {
setBusy(false)
if (!ok) {
toast.error(
next
? t('cloudFollow.followFailed', 'Couldn’t follow this workspace')
: t('cloudFollow.unfollowFailed', 'Couldn’t unfollow this workspace')
)
}
})
}
return (
<CloudWorkspaceFollowers
entries={entries}
directory={directory}
selfId={selfId}
following={null}
following={following}
busy={busy}
onToggleFollow={toggle}
onOpenMember={(userId) => (userId === selfId ? undefined : openTeammateProfile(userId))}
/>
)
@@ -2,17 +2,22 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTeamWorkspacesLive } from './team-workspaces-live'
import {
setCloudWorkspaceFollow,
setTeamWorkspacesLiveForTests,
useTeamWorkspacesLive
} from './team-workspaces-live'
import { resetCloudWorkspacesAvailabilityForFixtures } from './use-cloud-workspaces'
import { workspace } from './cloud-workspaces.test-fixture'
const list = vi.fn()
const follow = vi.fn()
beforeEach(() => {
list.mockReset()
list.mockResolvedValue({ ok: true, value: [workspace({ title: 'Checkout' })] })
Object.assign(window, {
api: { cloudWorkspaces: { available: async () => true, list } }
api: { cloudWorkspaces: { available: async () => true, list, follow } }
})
resetCloudWorkspacesAvailabilityForFixtures()
})
@@ -42,4 +47,14 @@ describe('useTeamWorkspacesLive (PARITY-FOLLOW)', () => {
expect(view.getByTestId('a').textContent).toBe('none')
expect(list).not.toHaveBeenCalled()
})
it('shows a follow at once from the server answer, and leaves the list alone on a refusal', async () => {
const w = workspace({ title: 'Checkout', following: false, followers: [] })
setTeamWorkspacesLiveForTests('org:acme', [w])
follow.mockResolvedValueOnce({ ok: true, value: { following: true, followers: ['user_ada'] } })
expect(await setCloudWorkspaceFollow(w.id, true)).toBe(true)
expect(follow).toHaveBeenCalledWith(w.id, true)
follow.mockResolvedValueOnce({ ok: false, error: 'forbidden' })
expect(await setCloudWorkspaceFollow(w.id, false)).toBe(false)
})
})
@@ -87,6 +87,32 @@ export function useTeamWorkspacesLive(account: string | null): readonly CloudWor
)
}
/** A local edit (a follow) shown at once, until the next read confirms it. */
export function patchTeamWorkspaceLive(id: string, patch: Partial<CloudWorkspace>): void {
if (snapshot) {
publish({
...snapshot,
workspaces: snapshot.workspaces.map((w) => (w.id === id ? { ...w, ...patch } : w))
})
}
}
/**
* Follow or unfollow (PARITY-FOLLOW). The live list updates from the server's answer; a refusal
* leaves it as it was and resolves false (the caller says so).
*/
export async function setCloudWorkspaceFollow(id: string, follow: boolean): Promise<boolean> {
const result = await window.api.cloudWorkspaces.follow(id, follow).catch(() => null)
if (!result?.ok) {
return false
}
patchTeamWorkspaceLive(id, {
following: result.value.following,
followers: result.value.followers
})
return true
}
/** Test and fixture seam: what the shared read last returned. */
export function setTeamWorkspacesLiveForTests(
account: string | null,
@@ -1,19 +1,27 @@
// PARITY-FOLLOW: the sidebar's Team list for a team account, under the nav and above Projects.
// Who is on the team comes from the roster; what each teammate is doing now comes from the shared
// live read of the team's workspaces. A personal account, or a team of one, shows nothing.
// PARITY-FOLLOW: the sidebar's Team list and Following section for a team account, under the nav
// and above Projects. Who is on the team comes from the roster; what each teammate is doing now, and
// which workspaces this member follows, from the shared live read of the team's workspaces. A
// personal account shows nothing; a team of one shows only what it follows.
import React, { useMemo } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { useOrcaAccountKey } from '@/hooks/use-orca-account-key'
import { useOrgRoster } from '@/components/billing/team/use-org-roster'
import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store'
import { useTeamWorkspacesLive } from '@/components/cloud-workspaces/team-workspaces-live'
import {
setCloudWorkspaceFollow,
useTeamWorkspacesLive
} from '@/components/cloud-workspaces/team-workspaces-live'
import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace'
import { useMemberDirectory } from '@/components/cloud-workspaces/use-cloud-workspace-row-actions'
import { useCloudWorkspacesAvailable } from '@/components/cloud-workspaces/use-cloud-workspaces'
import { openTeammateProfile } from '@/components/teammates/teammate-profile-store'
import { SidebarTeamList } from './SidebarTeamSections'
import { teammateActivities } from './team-activity'
import { SidebarFollowingSection, SidebarTeamList } from './SidebarTeamSections'
import { followedWorkspaces, teammateActivities } from './team-activity'
export function SidebarTeamPanel(): React.JSX.Element | null {
const t = useTranslate()
const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud)
const account = useOrcaAccountKey()
const available = useCloudWorkspacesAvailable()
@@ -30,16 +38,39 @@ export function SidebarTeamPanel(): React.JSX.Element | null {
[roster.result]
)
const members = useMemo(() => teammateActivities(memberIds, live), [memberIds, live])
const followed = useMemo(() => followedWorkspaces(live, selfId), [live, selfId])
if (!orgId || !available) {
return null
}
const open = (sandboxId: string): void => {
const workspace = live?.find((w) => w.id === sandboxId)
if (workspace) {
void openCloudWorkspace(workspace)
}
}
const unfollow = (sandboxId: string): void => {
void setCloudWorkspaceFollow(sandboxId, false).then((ok) => {
if (!ok) {
toast.error(t('cloudFollow.unfollowFailed', 'Couldn’t unfollow this workspace'))
}
})
}
return (
<SidebarTeamList
members={members}
directory={directory}
selfId={selfId}
onOpenProfile={openTeammateProfile}
onShowWorkspaces={(userId) => openCloudWorkspacePicker({ kind: 'teammate', userId })}
/>
<>
<SidebarTeamList
members={members}
directory={directory}
selfId={selfId}
onOpenProfile={openTeammateProfile}
onShowWorkspaces={(userId) => openCloudWorkspacePicker({ kind: 'teammate', userId })}
/>
<SidebarFollowingSection
workspaces={followed}
directory={directory}
selfId={selfId}
onOpen={open}
onUnfollow={unfollow}
/>
</>
)
}
@@ -2,7 +2,7 @@
import type { CloudWorkspace } from '../../../../../shared/cloud-workspaces/cloud-workspaces-wire'
import { formatWorkspaceNumber } from '../../../../../shared/cloud-workspaces/cloud-workspaces-wire'
import { repoLabel } from '@/components/cloud-workspaces/cloud-workspace-format'
import type { SidebarTeammate } from './SidebarTeamSections'
import type { SidebarFollowedWorkspace, SidebarTeammate } from './SidebarTeamSections'
/** A workspace by name (people recognise names); its W-number, then its repo, as fallbacks. */
export function workspaceDisplayLabel(
@@ -47,3 +47,31 @@ export function teammateActivities(
}
})
}
function followedStatus(w: CloudWorkspace): SidebarFollowedWorkspace['status'] {
if (w.archivedAt !== null) {
return 'archived'
}
if (w.status === 'running') {
return 'running'
}
return w.status === 'creating' || w.status === 'resuming' ? 'waking' : 'asleep'
}
/** Teammates' workspaces this member follows, most recently active first (PARITY-FOLLOW). */
export function followedWorkspaces(
workspaces: readonly CloudWorkspace[] | null,
selfId: string | null
): SidebarFollowedWorkspace[] {
return (workspaces ?? [])
.filter((w) => w.following === true && w.createdBy !== selfId)
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
.map((w) => ({
sandboxId: w.id,
title: workspaceDisplayLabel(w),
numberLabel: w.number ? formatWorkspaceNumber(w.number) : null,
creatorId: w.createdBy,
status: followedStatus(w),
hereNow: new Set(w.presence.map((p) => p.userId).filter((id) => id !== selfId)).size
}))
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { presence, SELF_ID, workspace } from '../../cloud-workspaces/cloud-workspaces.test-fixture'
import { followOffer } from '../../cloud-workspaces/CloudWorkspacePeople'
import { followedWorkspaces } from './team-activity'
describe('followedWorkspaces (PARITY-FOLLOW)', () => {
it("lists teammates' workspaces you follow, most recently active first, with who's here", () => {
const rows = followedWorkspaces(
[
workspace({ title: 'Old', createdBy: 'user_grace', following: true, lastActivityAt: 1 }),
workspace({
title: 'Checkout',
number: 582,
createdBy: 'user_linus',
following: true,
lastActivityAt: 9,
presence: [presence('user_mae'), presence(SELF_ID), presence('user_mae', 'typing')]
}),
workspace({ title: 'Not followed', createdBy: 'user_grace', following: false }),
workspace({ title: 'Mine', createdBy: SELF_ID, following: true }),
workspace({
title: 'Asleep',
createdBy: 'user_grace',
following: true,
status: 'suspended',
lastActivityAt: 0
})
],
SELF_ID
)
expect(rows.map((r) => [r.title, r.status, r.hereNow, r.numberLabel])).toEqual([
['Checkout', 'running', 1, 'W-582'],
['Old', 'running', 0, expect.any(String)],
['Asleep', 'asleep', 0, expect.any(String)]
])
})
})
describe('followOffer (PARITY-FOLLOW)', () => {
it("offers Follow on a teammate's workspace, never on your own or from an older control plane", () => {
expect(followOffer(workspace({ createdBy: 'user_grace', following: false }), SELF_ID)).toBe(
false
)
expect(followOffer(workspace({ createdBy: 'user_grace', following: true }), SELF_ID)).toBe(true)
expect(followOffer(workspace({ createdBy: SELF_ID, following: false }), SELF_ID)).toBeNull()
expect(followOffer(workspace({ createdBy: 'user_grace' }), SELF_ID)).toBeNull()
})
})
+3 -1
View File
@@ -29732,7 +29732,9 @@
"following": "Following",
"unfollow": "Unfollow",
"follow": "Follow",
"unfollowNamed": "Unfollow {{title}}"
"unfollowNamed": "Unfollow {{title}}",
"followFailed": "Couldn’t follow this workspace",
"unfollowFailed": "Couldn’t unfollow this workspace"
},
"sidebarTeam": {
"title": "Team",
@@ -11,6 +11,7 @@ export function createWebCloudWorkspacesApi(): PreloadApi['cloudWorkspaces'] {
list: () => Promise.resolve(NOT_CONFIGURED),
get: () => Promise.resolve(NOT_CONFIGURED),
patch: () => Promise.resolve(NOT_CONFIGURED),
follow: () => Promise.resolve(NOT_CONFIGURED),
resolveLink: () => Promise.resolve(NOT_CONFIGURED),
openGrant: () => Promise.resolve(NOT_CONFIGURED),
readSnapshot: () => Promise.resolve(NOT_CONFIGURED),
@@ -88,6 +88,12 @@ export const CloudWorkspaceListResponseSchema = z.object({
routineRuns: CloudWorkspaceRoutineRunsSchema.optional().catch(undefined)
})
export const CloudWorkspaceResponseSchema = z.object({ workspace: CloudWorkspaceSchema })
/** PUT/DELETE /v1/team/workspaces/:id/follow (PARITY-FOLLOW). */
export const CloudWorkspaceFollowSchema = z.object({
following: z.boolean(),
followers: z.array(Id)
})
export type CloudWorkspaceFollow = z.infer<typeof CloudWorkspaceFollowSchema>
export const CloudWorkspacePresenceResponseSchema = z.object({
presence: z.array(CloudWorkspacePresenceSchema)
})