diff --git a/src/main/cloud-workspaces/cloud-workspaces-client.ts b/src/main/cloud-workspaces/cloud-workspaces-client.ts index b1f8be21a22..fb6d14f4cc2 100644 --- a/src/main/cloud-workspaces/cloud-workspaces-client.ts +++ b/src/main/cloud-workspaces/cloud-workspaces-client.ts @@ -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, {}) }, diff --git a/src/main/cloud-workspaces/cloud-workspaces-service.ts b/src/main/cloud-workspaces/cloud-workspaces-service.ts index 9050dd334a3..7a187e5f504 100644 --- a/src/main/cloud-workspaces/cloud-workspaces-service.ts +++ b/src/main/cloud-workspaces/cloud-workspaces-service.ts @@ -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) => diff --git a/src/main/ipc/cloud-workspaces.ts b/src/main/ipc/cloud-workspaces.ts index ed1724d6deb..cd97349d906 100644 --- a/src/main/ipc/cloud-workspaces.ts +++ b/src/main/ipc/cloud-workspaces.ts @@ -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() }) diff --git a/src/preload/api/cloud-workspaces-api.ts b/src/preload/api/cloud-workspaces-api.ts index 1a116b3c7d9..8466f5c6dda 100644 --- a/src/preload/api/cloud-workspaces-api.ts +++ b/src/preload/api/cloud-workspaces-api.ts @@ -1,5 +1,6 @@ import type { CloudWorkspace, + CloudWorkspaceFollow, CloudWorkspaceGrant, CloudOrgSlug, CloudWorkspaceLinkTarget, @@ -37,6 +38,8 @@ export type CloudWorkspacesApi = { list: (query: CloudWorkspaceListQuery) => Promise get: (id: string) => Promise> patch: (id: string, patch: CloudWorkspacePatch) => Promise> + /** Follow or unfollow a team workspace (PARITY-FOLLOW): the caller's state and its followers. */ + follow: (id: string, follow: boolean) => Promise> /** Resolves either link form; a slug link for another org fails with reason `other_org` and its `orgId`. */ resolveLink: ( target: CloudWorkspaceLinkTarget, diff --git a/src/preload/api/cloud-workspaces-bridge.ts b/src/preload/api/cloud-workspaces-bridge.ts index 4a700c65726..33d87488bdc 100644 --- a/src/preload/api/cloud-workspaces-bridge.ts +++ b/src/preload/api/cloud-workspaces-bridge.ts @@ -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 }), diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePeople.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePeople.tsx index 8edad9b3117..5fae8cac16a 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePeople.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePeople.tsx @@ -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 ( (userId === selfId ? undefined : openTeammateProfile(userId))} /> ) diff --git a/src/renderer/src/components/cloud-workspaces/team-workspaces-live.test.tsx b/src/renderer/src/components/cloud-workspaces/team-workspaces-live.test.tsx index a6601b57c26..23a2504fb5c 100644 --- a/src/renderer/src/components/cloud-workspaces/team-workspaces-live.test.tsx +++ b/src/renderer/src/components/cloud-workspaces/team-workspaces-live.test.tsx @@ -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) + }) }) diff --git a/src/renderer/src/components/cloud-workspaces/team-workspaces-live.ts b/src/renderer/src/components/cloud-workspaces/team-workspaces-live.ts index e3497145a7e..0e56023035f 100644 --- a/src/renderer/src/components/cloud-workspaces/team-workspaces-live.ts +++ b/src/renderer/src/components/cloud-workspaces/team-workspaces-live.ts @@ -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): 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 { + 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, diff --git a/src/renderer/src/components/sidebar/team/SidebarTeamPanel.tsx b/src/renderer/src/components/sidebar/team/SidebarTeamPanel.tsx index 231304a1c73..ff2e8896109 100644 --- a/src/renderer/src/components/sidebar/team/SidebarTeamPanel.tsx +++ b/src/renderer/src/components/sidebar/team/SidebarTeamPanel.tsx @@ -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 ( - openCloudWorkspacePicker({ kind: 'teammate', userId })} - /> + <> + openCloudWorkspacePicker({ kind: 'teammate', userId })} + /> + + ) } diff --git a/src/renderer/src/components/sidebar/team/team-activity.ts b/src/renderer/src/components/sidebar/team/team-activity.ts index 77ec0a1d99f..ed33bd54fda 100644 --- a/src/renderer/src/components/sidebar/team/team-activity.ts +++ b/src/renderer/src/components/sidebar/team/team-activity.ts @@ -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 + })) +} diff --git a/src/renderer/src/components/sidebar/team/team-followed.test.ts b/src/renderer/src/components/sidebar/team/team-followed.test.ts new file mode 100644 index 00000000000..635a794c38d --- /dev/null +++ b/src/renderer/src/components/sidebar/team/team-followed.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2c5d02887da..0e5a2706e64 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/web/preload-api/web-cloud-workspaces-api.ts b/src/renderer/src/web/preload-api/web-cloud-workspaces-api.ts index 041324fcfe2..1babcb8272a 100644 --- a/src/renderer/src/web/preload-api/web-cloud-workspaces-api.ts +++ b/src/renderer/src/web/preload-api/web-cloud-workspaces-api.ts @@ -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), diff --git a/src/shared/cloud-workspaces/cloud-workspaces-wire.ts b/src/shared/cloud-workspaces/cloud-workspaces-wire.ts index e90ad1e44b6..72db5a57fb3 100644 --- a/src/shared/cloud-workspaces/cloud-workspaces-wire.ts +++ b/src/shared/cloud-workspaces/cloud-workspaces-wire.ts @@ -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 export const CloudWorkspacePresenceResponseSchema = z.object({ presence: z.array(CloudWorkspacePresenceSchema) })