diff --git a/packages/control-plane/core/machines.ts b/packages/control-plane/core/machines.ts index 0f03d2e9..d8fcc5d8 100644 --- a/packages/control-plane/core/machines.ts +++ b/packages/control-plane/core/machines.ts @@ -128,6 +128,10 @@ export interface ProvisionMachineInput { /** A volume to reuse instead of creating one: a recreate or a machine-type * change keeps the member's disk. */ volumeId?: string; + /** Whether this machine gets its own volume when it has none to reuse. + * Defaults to true; false skips the volume entirely, so the VM's disk is + * the only one it has and nothing on it outlives the VM. */ + persistentVolume?: boolean; /** An existing machine row to bring back up, instead of inserting one. */ machineId?: string; recipe?: RecipeBootstrap; @@ -257,7 +261,9 @@ export async function provisionMachine( } // The member's own disk. It holds /var/lib/blitz, which is the docker // store and /workspace both, so a destroyed machine can come back on it. - const autoVolume = input.volumeId !== undefined + // A member row that asked for no persistent volume gets none: the VM's + // own disk is all there is, and it goes when the VM goes. + const autoVolume = input.volumeId !== undefined || input.persistentVolume === false ? null : await provisionWorkspaceVolume({ db: runtime.db, diff --git a/packages/control-plane/core/wire-machines.ts b/packages/control-plane/core/wire-machines.ts index af4a7976..a08f6f09 100644 --- a/packages/control-plane/core/wire-machines.ts +++ b/packages/control-plane/core/wire-machines.ts @@ -72,6 +72,10 @@ export interface AddWorkspaceMemberRequest { role: WorkspaceMemberRole; /** Per-member override of the workspace default. */ machineTypeId?: string; + /** Whether this member's machine gets its own persistent volume. Default + * true. False provisions the VM with no disk of its own, so nothing on it + * survives the VM — for a throwaway machine that has nothing to keep. */ + persistentVolume?: boolean; } export interface UpdateWorkspaceMemberRequest { @@ -84,6 +88,10 @@ export interface UpdateWorkspaceMemberRequest { export interface ProvisionMemberMachineRequest { /** Overrides the workspace default for this one machine (§1a). */ machineTypeId?: string; + /** Whether this member's machine gets its own persistent volume. Default + * true. False provisions the VM with no disk of its own, so nothing on it + * survives the VM — for a throwaway machine that has nothing to keep. */ + persistentVolume?: boolean; } export interface WorkspaceMemberResponse { diff --git a/packages/control-plane/core/wire.ts b/packages/control-plane/core/wire.ts index 7631386e..2bcae433 100644 --- a/packages/control-plane/core/wire.ts +++ b/packages/control-plane/core/wire.ts @@ -554,7 +554,13 @@ export interface CreateWorkspaceRequest { autoProvision?: boolean; /** Existing org members, added immediately. The creator is the first * workspace admin and never needs a row here. */ - members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[]; + members?: { + membershipId: string; + role: WorkspaceMemberRole; + machineTypeId?: string; + /** Default true; false gives that member's machine no volume. */ + persistentVolume?: boolean; + }[]; /** The only path where a credential value is sent. */ credentials?: { name: string; label?: string; value: string }[]; /** Copies config — default machine type, agent rule, repos, credential diff --git a/packages/control-plane/core/workspace-members.ts b/packages/control-plane/core/workspace-members.ts index 05e018f6..b20ad337 100644 --- a/packages/control-plane/core/workspace-members.ts +++ b/packages/control-plane/core/workspace-members.ts @@ -1,5 +1,12 @@ import { first, rows } from "./db.js"; -import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js"; +import { + HttpError, + isBoolean, + isRecord, + readJson, + requiredString, + type JsonValue, +} from "./http.js"; import { destroyMachine, machineFor, @@ -38,6 +45,12 @@ export function parseAddWorkspaceMember(value: JsonValue): AddWorkspaceMemberReq if (value.machineTypeId !== undefined && value.machineTypeId !== null) { result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); } + if (value.persistentVolume !== undefined && value.persistentVolume !== null) { + if (!isBoolean(value.persistentVolume)) { + throw new HttpError(400, "persistentVolume must be a boolean"); + } + result.persistentVolume = value.persistentVolume; + } return result; } @@ -47,6 +60,12 @@ function parseProvisionMemberMachine(value: JsonValue): ProvisionMemberMachineRe if (value.machineTypeId !== undefined && value.machineTypeId !== null) { result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); } + if (value.persistentVolume !== undefined && value.persistentVolume !== null) { + if (!isBoolean(value.persistentVolume)) { + throw new HttpError(400, "persistentVolume must be a boolean"); + } + result.persistentVolume = value.persistentVolume; + } return result; } @@ -68,6 +87,7 @@ function memberProvisionInput( machineTypeId: string, requestOrigin: string, existing: MachineRow | null, + persistentVolume?: boolean, ): ProvisionMachineInput { const input: ProvisionMachineInput = { workspace, @@ -77,6 +97,7 @@ function memberProvisionInput( }; if (existing !== null) input.machineId = existing.id; if (existing?.volume_id != null) input.volumeId = existing.volume_id; + if (persistentVolume !== undefined) input.persistentVolume = persistentVolume; return input; } @@ -138,6 +159,7 @@ export async function addWorkspaceMember( input.machineTypeId ?? workspace.default_machine_type_id, requestOrigin, existing, + input.persistentVolume, )) : existing; return { @@ -263,6 +285,7 @@ export function addWorkspaceMemberRoutes( input.machineTypeId ?? existing?.machine_type_id ?? workspace.default_machine_type_id, new URL(context.req.url).origin, existing, + input.persistentVolume, )); return context.json({ member: { diff --git a/packages/control-plane/test/helpers.ts b/packages/control-plane/test/helpers.ts index f3253808..1b81583e 100644 --- a/packages/control-plane/test/helpers.ts +++ b/packages/control-plane/test/helpers.ts @@ -86,6 +86,10 @@ export class FakeProviders implements VmProvider, VolumeProvider { detachCalls = 0; onCreate?: (machineId: string) => Promise; onDestroy?: (machineId: string) => Promise; + /** Undefined by default, so the fake places no volume of its own and a + * suite that never asked for one still sees `volume_id` null. A suite that + * exercises the auto-created volume sets it. */ + volumeLocation?: (machineTypeId: string) => string | null; capabilities() { // Ticket-capable from epoch: workspaces created in tests are new. diff --git a/packages/control-plane/test/member-machines.test.ts b/packages/control-plane/test/member-machines.test.ts index 1eb02200..4c65a3ab 100644 --- a/packages/control-plane/test/member-machines.test.ts +++ b/packages/control-plane/test/member-machines.test.ts @@ -430,6 +430,71 @@ describe("member machines", () => { expect((await machineRow(workspace.id, "personal"))?.state).toBe("destroyed"); }); + it("gives every member a volume unless their row asked for none", async () => { + const { app, providers } = harness(); + // The fake places no volume unless a suite asks it to; this one is about + // what happens when it can. + providers.volumeLocation = () => "test"; + const cookie = await operatorSession(app); + const keeper = await sameOrgSession("volume-keeper"); + const throwaway = await sameOrgSession("volume-throwaway"); + const later = await sameOrgSession("volume-later"); + + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [ + { membershipId: keeper.membershipId, role: "member" }, + { membershipId: throwaway.membershipId, role: "member", persistentVolume: false }, + ], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + + // Default true: an absent field is a member who keeps their disk. + expect((await machineRow(workspace.id, keeper.membershipId))?.volume_id).not.toBeNull(); + // False skips the existing helper, so there is no volume and no ownership + // row to reclaim later. + const bare = await machineRow(workspace.id, throwaway.membershipId); + expect(bare?.volume_id).toBeNull(); + expect(await env.DB + .prepare("SELECT COUNT(*) AS count FROM volume_ownership") + .first("count")).toBe(2); + + // The manual provision carries the same field, for a workspace whose + // auto-provision is off or a machine that was destroyed. + const added = await appRequest(app, `/workspaces/${workspace.id}/members`, { + ...json({ membershipId: later.membershipId, role: "viewer" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(added.status).toBe(201); + expect((await appRequest(app, `/workspaces/${workspace.id}/members/${later.membershipId}`, { + ...json({ role: "member" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(200); + const machineId = await machineIdFor(workspace.id, later.membershipId); + expect((await appRequest(app, `/machines/${machineId}`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(200); + await env.DB.prepare("UPDATE machines SET volume_id = NULL WHERE id = ?1") + .bind(machineId).run(); + + const back = await appRequest(app, `/workspaces/${workspace.id}/members/${later.membershipId}/machine`, { + ...json({ persistentVolume: false }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(back.status).toBe(201); + expect((await machineRow(workspace.id, later.membershipId))?.volume_id).toBeNull(); + + // A field that is not a boolean is refused rather than coerced. + expect((await appRequest(app, `/workspaces/${workspace.id}/members`, { + ...json({ membershipId: later.membershipId, role: "member", persistentVolume: "no" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(400); + }); + it("counts machines against vm_limit, not workspaces", async () => { const { app } = harness(); const cookie = await operatorSession(app); diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index 659136f0..b3f64fe8 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -194,6 +194,7 @@ const addWorkspaceMemberRequest: SharedShape< membershipId: viewerMember.membershipId, role: "member", machineTypeId: pricedMachineType.id, + persistentVolume: false, }; const updateWorkspaceMemberRequest: SharedShape< @@ -204,7 +205,7 @@ const updateWorkspaceMemberRequest: SharedShape< const provisionMemberMachineRequest: SharedShape< wire.ProvisionMemberMachineRequest, schema.ProvisionMemberMachineRequest -> = { machineTypeId: pricedMachineType.id }; +> = { machineTypeId: pricedMachineType.id, persistentVolume: true }; // Every settings field at once. `agentRuleId` also travels as an explicit // null — the way back to the built-in doc — which is different ground than a @@ -383,7 +384,12 @@ const createWorkspaceRequest: SharedShape< machineTypeId: machineType.id, defaultMachineTypeId: machineType.id, autoProvision: false, - members: [{ membershipId: viewerMember.membershipId, role: "member", machineTypeId: machineType.id }], + members: [{ + membershipId: viewerMember.membershipId, + role: "member", + machineTypeId: machineType.id, + persistentVolume: false, + }], credentials: [{ name: workspaceCredential.name, label: "live", value: "sk_test_only" }], cloneFromWorkspaceId: "workspace", sshPublicKey: "ssh-ed25519 AAAAcaller", diff --git a/packages/schema/src/api.ts b/packages/schema/src/api.ts index 4590ee2e..aa24b83b 100644 --- a/packages/schema/src/api.ts +++ b/packages/schema/src/api.ts @@ -31,7 +31,13 @@ export interface CreateWorkspaceRequest { autoProvision?: boolean; /** Existing org members, added immediately. The creator is the first * workspace admin and never needs a row here. */ - members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[]; + members?: { + membershipId: string; + role: WorkspaceMemberRole; + machineTypeId?: string; + /** Default true; false gives that member's machine no volume. */ + persistentVolume?: boolean; + }[]; /** The only path where a credential value is sent. */ credentials?: { name: string; label?: string; value: string }[]; /** Copies config — default machine type, agent rule, repos, credential diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index 9bc73a30..87d5d8b5 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -159,6 +159,10 @@ export interface AddWorkspaceMemberRequest { role: WorkspaceMemberRole; /** Per-member override of the workspace default. */ machineTypeId?: string; + /** Whether this member's machine gets its own persistent volume. Default + * true. False provisions the VM with no disk of its own, so nothing on it + * survives the VM — for a throwaway machine that has nothing to keep. */ + persistentVolume?: boolean; } export interface UpdateWorkspaceMemberRequest { @@ -171,6 +175,10 @@ export interface UpdateWorkspaceMemberRequest { export interface ProvisionMemberMachineRequest { /** Overrides the workspace default for this one machine (§1a). */ machineTypeId?: string; + /** Whether this member's machine gets its own persistent volume. Default + * true. False provisions the VM with no disk of its own, so nothing on it + * survives the VM — for a throwaway machine that has nothing to keep. */ + persistentVolume?: boolean; } export interface WorkspaceMemberResponse { diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 8fece8fb..27e4fb27 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -187,8 +187,9 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { * blank create. Cleared with the dialog. */ const [cloneFromWorkspaceId, setCloneFromWorkspaceId] = useState(null); const [details, setDetails] = useState< - { workspaceId: string; tab: WorkspaceDetailsTab } | null + { workspaceId: string; tab: WorkspaceDetailsTab; focusAddMember?: boolean } | null >(null); + const [machineWorkspaceId, setMachineWorkspaceId] = useState(null); const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false); const [createWorkspaceError, setCreateWorkspaceError] = useState(null); const [confirmation, setConfirmation] = useState(null); @@ -303,7 +304,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { } }, [api]); const listMachineTypes = useCallback(() => api.listMachineTypes(), [api]); - const listVolumes = useCallback(() => api.listVolumes(), [api]); const refreshWorkspaceRecords = useCallback(async () => { try { const records = await api.listWorkspaces(); @@ -773,19 +773,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }); }, [activeWorkspaceId, setWorkspaceTabs]); - /** The strip's surface icons focus a panel. They open, they never close: - * the right icon strip owns the toggle. */ - const openWorkspacePanel = useCallback((panel: WorkspaceDrawerSegment) => { - if (!activeWorkspaceId) return; - updateWorkspaceTabs((tabs) => showPanelTab(tabs, panel)); - if (mobileWebApp) { - setDrawerOpen(false); - setFilesDrawerOpen(true); - return; - } - setFocusedRegion('side'); - }, [activeWorkspaceId, mobileWebApp, updateWorkspaceTabs]); - const toggleFiles = useCallback(() => { if (!activeWorkspaceId) return; if (mobileWebApp) { @@ -828,6 +815,15 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }); }, []); + /** The tile's inline rename. The name is the workspace's own settings field, + * so it goes through the same PATCH the settings tab writes; the tile shows + * the new name at once and the next poll agrees. */ + const renameWorkspace = useCallback((workspaceId: string, name: string) => { + dispatch({ type: 'workspace_renamed', workspaceId, title: name }); + void client.updateWorkspace(workspaceId, { name }) + .catch((caught: Error) => setError(caught.message)); + }, [client]); + const retryWorkspace = useCallback(async (workspaceId: string) => { const workspace = storeRef.current.workspaces.find(({ id }) => id === workspaceId); if (workspace?.retryAction === 'destroy') { @@ -1375,12 +1371,20 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ? railSessions : []} activeSessionId={railActiveSessionId ?? ''} - openPanels={openPanels} - pendingRequestCount={activePendingRequests.length} + livePorts={orderedLivePorts} + previewLinks={orderedPreviewLinks} drawerOpen={drawerOpen} onSelectWorkspace={selectWorkspace} + onRenameWorkspace={renameWorkspace} + onOpenWorkspaceSettings={(workspaceId) => { + if (mobileWebApp) setDrawerOpen(false); + setDetails({ workspaceId, tab: 'settings' }); + }} + onInviteToWorkspace={(workspaceId) => { + if (mobileWebApp) setDrawerOpen(false); + setDetails({ workspaceId, tab: 'members', focusAddMember: true }); + }} onCreateWorkspace={() => setShowCreateWorkspace(true)} - onOpenPanel={openWorkspacePanel} onSwitchOrg={(orgId) => { void client.switchOrg(orgId).then(() => window.location.reload()); }} @@ -1389,6 +1393,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { onOpenSettings={() => navigateToSettings('profile')} onSelectSession={selectTtydSession} onSpawnSession={spawnTtydSession} + onOpenPreview={(port) => { openPreviewPort(port); }} + onOpenPreviewLink={(url, title) => { openPreviewLink(url, title); }} onOpenWorkspaceMembers={(workspaceId) => { if (mobileWebApp) setDrawerOpen(false); setDetails({ workspaceId, tab: 'members' }); @@ -1397,6 +1403,10 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { if (mobileWebApp) setDrawerOpen(false); setDetails({ workspaceId, tab: 'members' }); }} + onOpenWorkspaceMachine={(workspaceId) => { + if (mobileWebApp) setDrawerOpen(false); + setMachineWorkspaceId(workspaceId); + }} onCloseDrawer={() => setDrawerOpen(false)} /> ); @@ -1417,7 +1427,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { createWorkspaceBusy={createWorkspaceBusy} createWorkspaceError={createWorkspaceError} listMachineTypes={listMachineTypes} - listVolumes={listVolumes} cloneFromWorkspaceId={cloneFromWorkspaceId} onCancelCreateWorkspace={() => { if (createWorkspaceBusy) return; @@ -1427,6 +1436,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { onCreateWorkspace={(input) => { void createWorkspace(input); }} details={details} onCloseDetails={() => setDetails(null)} + machineWorkspaceId={machineWorkspaceId} + onCloseMachine={() => setMachineWorkspaceId(null)} onCloneWorkspace={(workspaceId) => { // "New workspace from existing" IS the template now (§0): the create // dialog opens carrying the source, and the server copies its config. diff --git a/packages/webapp/src/CreateWorkspaceDialog.tsx b/packages/webapp/src/CreateWorkspaceDialog.tsx index 118afcc8..3a095a65 100644 --- a/packages/webapp/src/CreateWorkspaceDialog.tsx +++ b/packages/webapp/src/CreateWorkspaceDialog.tsx @@ -4,7 +4,6 @@ import type { MachineType, MachineTypeProviderFailure, MachineTypeProviderStatus, - Volume, } from '@blitzos/schema'; import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; import { AgentRulesPicker, type AgentRulesApi } from './AgentRulesPicker'; @@ -47,7 +46,6 @@ type CreateWorkspaceDialogProps = { 'connectStartUrl' | 'listGithubInstallations' | 'listGithubRepositories' | 'listMembers' >; listMachineTypes: () => Promise; - listVolumes: () => Promise; /** The workspace whose config this create copies — "new workspace from * existing", which replaced templates (§0). Members and credential values * are never copied. */ @@ -68,7 +66,6 @@ export function CreateWorkspaceDialog({ saveComputeCredential, client, listMachineTypes, - listVolumes, cloneFromWorkspaceId = null, cloneFromWorkspaceName = null, viewerName = 'You', @@ -80,7 +77,6 @@ export function CreateWorkspaceDialog({ const [machines, setMachines] = useState([]); const [machineFailures, setMachineFailures] = useState([]); const [providerStatuses, setProviderStatuses] = useState([]); - const [volumes, setVolumes] = useState([]); const [orgMembers, setOrgMembers] = useState([]); const [selectedMachineType, setSelectedMachineType] = useState(''); const [loading, setLoading] = useState(true); @@ -92,8 +88,6 @@ export function CreateWorkspaceDialog({ const [members, setMembers] = useState([]); const [credentials, setCredentials] = useState([]); const submitted = useRef(false); - const selectedMachine = machines.find(({ id }) => id === selectedMachineType); - const supportsVolumes = selectedMachine?.supportsVolumes ?? false; const credentialRequiredProviders = providerStatuses.flatMap(({ providerId, access }) => access === 'credential-required' && isComputeCredentialProvider(providerId) ? [providerId] @@ -124,9 +118,8 @@ export function CreateWorkspaceDialog({ setMachineFailures([]); void Promise.allSettled([ listMachineTypes(), - listVolumes(), client.listMembers(), - ]).then(([machineResult, volumeResult, memberResult]) => { + ]).then(([machineResult, memberResult]) => { if (!mounted) return; setOrgMembers(memberResult.status === 'fulfilled' ? memberResult.value.members : []); if (machineResult.status === 'rejected') { @@ -137,11 +130,10 @@ export function CreateWorkspaceDialog({ return; } installMachineTypes(machineResult.value); - setVolumes(volumeResult.status === 'fulfilled' ? volumeResult.value : []); setLoading(false); }); return () => { mounted = false; }; - }, [client, installMachineTypes, listMachineTypes, listVolumes]); + }, [client, installMachineTypes, listMachineTypes]); const machineFailureItems = machineFailures.map((failure) => (
  • {failure.providerId}: {failure.error}
  • @@ -157,27 +149,28 @@ export function CreateWorkspaceDialog({ const data = new FormData(event.currentTarget); const name = String(data.get('name') ?? '').trim(); const sshPublicKey = String(data.get('sshPublicKey') ?? '').trim(); - const volumeId = String(data.get('volumeId') ?? ''); submitted.current = true; const input: CreateWorkspaceDialogInput = { machineTypeId: selectedMachineType, }; if (name) input.name = name; if (sshPublicKey) input.sshPublicKey = sshPublicKey; - if (volumeId) input.volumeId = volumeId; if (repos.length > 0) input.repos = repos; if (agentRuleId !== null) input.agentRuleId = agentRuleId; if (cloneFromWorkspaceId !== null) input.cloneFromWorkspaceId = cloneFromWorkspaceId; if (members.length > 0) { - input.members = members.map((member) => ( - member.machineTypeId === WORKSPACE_DEFAULT_MACHINE_TYPE - ? { membershipId: member.membershipId, role: member.role } - : { - membershipId: member.membershipId, - role: member.role, - machineTypeId: member.machineTypeId, - } - )); + input.members = members.map((member) => { + const row: NonNullable[number] = { + membershipId: member.membershipId, + role: member.role, + }; + if (member.machineTypeId !== WORKSPACE_DEFAULT_MACHINE_TYPE) { + row.machineTypeId = member.machineTypeId; + } + // True is the server's default, so only the refusal travels. + if (!member.persistentVolume) row.persistentVolume = false; + return row; + }); } const namedCredentials = credentials.filter( (credential) => credential.name.trim() !== '' && credential.value !== '', @@ -374,27 +367,6 @@ export function CreateWorkspaceDialog({ -
    -
    -

    Volume

    -

    Optionally attach an available volume.

    -
    - -
    - {/* A clone already carries its source's repository list, and the two * sources never mix — a body naming both is refused with a 400. */} {cloneFromWorkspaceId === null &&
    diff --git a/packages/webapp/src/MyMachineDialog.tsx b/packages/webapp/src/MyMachineDialog.tsx new file mode 100644 index 00000000..e2b9f8e4 --- /dev/null +++ b/packages/webapp/src/MyMachineDialog.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + ListMachineTypesResponse, + MachineType, + MachineView, + WorkspaceMemberView, +} from '@blitzos/schema'; +import type { ControlPlaneClient } from './api'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { monthlyPriceLabel } from './MachineCatalogGrid'; +import { MachineTypeSelect } from './MachineTypeSelect'; +import { ModalOverlay } from './ModalOverlay'; +import { machineActionsFor, type MachineAction } from './WorkspaceMembersEditor'; +import type { CloudWorkspaceModel } from './workspace-store'; + +const ACTION_LABELS = { + provision: 'Provision', + stop: 'Stop', + start: 'Start', + recreate: 'Recreate', + destroy: 'Destroy', +} satisfies Record; + +/** + * Who may run each verb on their OWN machine (plans/MEMBER-MACHINES.md §3). + * + * `stop` and `start` are a member's own business, and so is bringing a machine + * row that exists back up. Replacing, destroying and re-typing a machine + * interrupt work and belong to a workspace admin — as does provisioning where + * there is no machine row at all, which is the member-add route in disguise. + */ +function needsAdmin(action: MachineAction, machine: MachineView | null): boolean { + if (action === 'stop' || action === 'start') return false; + if (action === 'provision') return machine === null; + return true; +} + +function dateLabel(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) return 'Unavailable'; + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(timestamp)); +} + +function Detail({ label, value }: { label: string; value: string }) { + return
    {label}
    {value}
    ; +} + +/** The volume's location, so a type change can refuse what cannot reach it. + * Derived from the machine's current type, because the volume was created in + * that type's location. */ +function volumeLocationOf( + machine: MachineView | null, + machines: readonly MachineType[], +): string | null { + if (machine === null || machine.volumeId === null) return null; + const current = machines.find(({ id }) => id === machine.machineTypeId); + if (current === undefined) return null; + return current.location || current.id.split('@').at(-1) || null; +} + +/** The people to ask for what this member may not do themselves. This is the + * whole of "request a change" for now: there is no request to file, so the + * refusal names who can act instead of pretending somebody was told. */ +function askLine(members: readonly WorkspaceMemberView[]): string { + const admins = members.filter(({ role }) => role === 'admin').map(({ name }) => name); + if (admins.length === 0) return 'Ask a workspace admin.'; + return `Ask a workspace admin: ${admins.join(', ')}`; +} + +/** + * The member's own machine, in the workspace-details chrome and without its + * tab row: there is one thing to read here. + * + * Everything it shows already exists on the wire — the member row of + * `WorkspaceView` carries the machine, and the catalog names its size. What it + * adds is the §3 matrix in the first person: a verb this member may not run is + * shown disabled with the admins to ask, rather than hidden or refused after + * the click. + */ +export function MyMachineDialog({ + client, + workspace, + membershipId, + listMachineTypes, + onClose, +}: { + client: ControlPlaneClient; + workspace: CloudWorkspaceModel; + /** The requesting member's membership, which is what keys a machine. */ + membershipId: string | null; + listMachineTypes: () => Promise; + onClose: () => void; +}) { + const closeButton = useRef(null); + const [machines, setMachines] = useState([]); + const [error, setError] = useState(null); + const [pendingTypeId, setPendingTypeId] = useState(null); + + useEffect(() => { closeButton.current?.focus(); }, []); + useEffect(() => { + let cancelled = false; + void listMachineTypes() + .then((response) => { if (!cancelled) setMachines(response.machineTypes); }) + .catch((caught: Error) => { if (!cancelled) setError(caught.message); }); + return () => { cancelled = true; }; + }, [listMachineTypes]); + + const run = useCallback((action: Promise) => { + void action.then(() => setError(null)).catch((caught: Error) => setError(caught.message)); + }, []); + + const member = workspace.members.find((row) => row.membershipId === membershipId); + const machine = member?.machine ?? null; + // A workspace admin, or an org admin reaching in implicitly (§3). + const admin = workspace.myRole === 'admin' || workspace.myRole === null; + const type = machines.find(({ id }) => id === machine?.machineTypeId); + const price = monthlyPriceLabel(type?.monthlyPrice); + + const act = (action: MachineAction) => { + if (machine === null) { + if (action === 'provision') { + run(client.provisionMemberMachine(workspace.id, membershipId ?? '', {})); + } + return; + } + if (action === 'provision') run(client.provisionMachine(machine.id)); + if (action === 'stop') run(client.stopMachine(machine.id)); + if (action === 'start') run(client.startMachine(machine.id)); + if (action === 'recreate') run(client.recreateMachine(machine.id)); + if (action === 'destroy') run(client.destroyMachine(machine.id)); + }; + + const actions = member === undefined || member.role === 'viewer' + ? [] + : machineActionsFor(machine); + const refused = actions.filter((action) => needsAdmin(action, machine) && !admin); + + return ( + +
    +
    +

    My machine “{workspace.title}”

    + +
    +
    + {error !== null &&

    {error}

    } + {member === undefined ? ( +

    You are not a member of this workspace.

    + ) : member.role === 'viewer' ? ( +

    + A viewer holds no machine. Viewers watch the workspace; they do not + run it. {askLine(workspace.members)} to change your role. +

    + ) : ( +
    +

    Machine

    +
    + + + + + + + + +
    + {machine?.error != null && ( +

    {machine.error}

    + )} + +

    Machine type

    + {admin && machine !== null ? ( + { + if (machineTypeId !== machine.machineTypeId) setPendingTypeId(machineTypeId); + }} + /> + ) : ( +

    + {machine === null + ? 'There is no machine to re-type yet.' + : `Changing a machine's type is workspace-admin work. ${askLine(workspace.members)}`} +

    + )} + +

    Lifecycle

    + {actions.length === 0 && ( +

    + {machine === null + ? 'You have no machine yet.' + : `A machine that is ${machine.state} accepts nothing until it arrives.`} +

    + )} +
    + {actions.map((action) => { + const blocked = needsAdmin(action, machine) && !admin; + return ( + + ); + })} +
    + {refused.length > 0 && ( +

    {askLine(workspace.members)}

    + )} +
    + )} +
    +
    + {pendingTypeId !== null && machine !== null && ( + setPendingTypeId(null)} + onConfirm={() => { + setPendingTypeId(null); + run(client.setMachineType(machine.id, { machineTypeId: pendingTypeId })); + }} + /> + )} +
    + ); +} diff --git a/packages/webapp/src/NewTabMenu.tsx b/packages/webapp/src/NewTabMenu.tsx new file mode 100644 index 00000000..831e71d7 --- /dev/null +++ b/packages/webapp/src/NewTabMenu.tsx @@ -0,0 +1,87 @@ +import { SessionTypeIcon } from './SessionTypeIcon'; +import { previewLinkLabel, type LivePort, type PreviewLink } from './preview'; +import { NATIVE_CHAT_ENABLED } from './product-features'; + +export type SpawnSessionType = 'claude' | 'codex' | 'terminal' | 'chat'; + +export const SPAWN_SESSION_LABELS = { + chat: 'Chat', + claude: 'Claude', + codex: 'Codex', + terminal: 'Terminal', +} satisfies Record; + +const SPAWN_SESSION_TYPES: SpawnSessionType[] = [ + ...(NATIVE_CHAT_ENABLED ? ['chat' as const] : []), + 'claude', + 'codex', + 'terminal', +]; + +/** What a new tab can be: a session to spawn, a live port, or a published + * preview link. The tab strip's "+" and the session rail's pinned action both + * render this one menu, so the two can never offer different things. Each call + * site keeps its own anchor: it passes the positioning class and decides + * whether the menu is mounted or merely hidden. */ +export function NewTabMenu({ + className, + hidden, + livePorts = [], + previewLinks = [], + onSpawn, + onOpenPreview, + onOpenPreviewLink, +}: { + className?: string; + hidden?: boolean; + livePorts?: LivePort[]; + previewLinks?: PreviewLink[]; + onSpawn: (type: SpawnSessionType) => void; + onOpenPreview: (port: number) => void; + onOpenPreviewLink: (url: string, title: string) => void; +}) { + return ( +
    )} @@ -355,11 +380,23 @@ export function WorkspaceDetailsDialog({ onSave={(input) => run(client.updateWorkspace(workspaceId, input))} onAddRepo={addRepo} onRemoveRepo={removeRepo} - onClone={onClone} - onDelete={onDelete} /> )} + {(onClone !== null || onDelete !== null) && ( +
    + {onClone && ( + + )} + {onDelete && ( + + )} +
    + )} {pendingTypeChange !== null && ( void; }) { const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); + const field = useRef(null); + useEffect(() => { if (autoFocus) field.current?.focus(); }, [autoFocus]); const trimmed = query.trim().toLowerCase(); const matches = candidates .filter((member) => trimmed === '' @@ -89,6 +97,7 @@ function AddMemberSearch({ return (
    void; onMachineTypeChange: (machineTypeId: string) => void; + onPersistentVolumeChange: (persistentVolume: boolean) => void; onMachineAction: ((action: MachineAction) => void) | null; onRemove: () => void; }) { // A viewer never holds a machine (§2.2), so the type select would be a // control over something that does not exist. const showMachine = role !== 'viewer'; + // The volume is created with the machine, so the toggle is a choice only + // while there is no machine. On a row that has one it reports the disk that + // exists rather than offering to change it, which this route cannot do. + const volumeDecided = machine !== null; + // The pinned creator row of a draft has no choice to make: the workspace + // creator's own machine is provisioned before any member row is read. + const showVolume = showMachine && (volumeDecided || !pinned); const actions = onMachineAction === null ? [] : machineActionsFor(machine); return (
    - + {name} {pinned && Workspace owner} @@ -208,6 +228,18 @@ function MemberRow({ onChange={onMachineTypeChange} /> )} + {showVolume && ( + + )} {showMachine && actions.length > 0 && ( void; + onAdd: (input: { + membershipId: string; + role: WorkspaceMemberRole; + machineTypeId: string; + persistentVolume: boolean; + }) => void; onRoleChange: (membershipId: string, role: WorkspaceMemberRole) => void; onMachineTypeChange: (member: WorkspaceMemberView, machineTypeId: string) => void; - onMachineAction: (member: WorkspaceMemberView, action: MachineAction) => void; + onMachineAction: ( + member: WorkspaceMemberView, + action: MachineAction, + options: { persistentVolume: boolean }, + ) => void; onRemove: (member: WorkspaceMemberView) => void; }; @@ -268,6 +309,7 @@ export function WorkspaceMembersEditor({ orgMembers, machines, defaultMachineTypeId, + autoFocusAdd = false, viewerName = 'You', viewerAvatarUrl = null, }: { @@ -275,11 +317,17 @@ export function WorkspaceMembersEditor({ orgMembers: MemberView[]; machines: readonly MachineType[]; defaultMachineTypeId: string; + /** Opens with the add-member field focused. */ + autoFocusAdd?: boolean; /** Draft mode pins the creator as the first workspace admin. Live mode * reads the owner off the member rows, so it never needs this. */ viewerName?: string; viewerAvatarUrl?: string | null; }) { + // What a live row asks the NEXT provision for. A draft row carries its own + // answer in the create request; a live row has nowhere to keep one until + // there is a machine, so the editor holds it until provision reads it. + const [volumeIntent, setVolumeIntent] = useState>({}); const listed = new Set(mode.members.map(({ membershipId }) => membershipId)); const readOnly = mode.kind === 'live' && mode.readOnly; const candidates = orgMembers.filter((member) => @@ -294,12 +342,14 @@ export function WorkspaceMembersEditor({ {!readOnly && ( { if (mode.kind === 'draft') { mode.onChange([...mode.members, { membershipId: member.id, role: 'member', machineTypeId: WORKSPACE_DEFAULT_MACHINE_TYPE, + persistentVolume: true, }]); return; } @@ -307,6 +357,7 @@ export function WorkspaceMembersEditor({ membershipId: member.id, role: 'member', machineTypeId: WORKSPACE_DEFAULT_MACHINE_TYPE, + persistentVolume: true, }); }} /> @@ -318,6 +369,7 @@ export function WorkspaceMembersEditor({ avatarUrl={viewerAvatarUrl} role="admin" machineTypeId={WORKSPACE_DEFAULT_MACHINE_TYPE} + persistentVolume machine={null} machines={machines} defaultMachineTypeId={defaultMachineTypeId} @@ -325,6 +377,7 @@ export function WorkspaceMembersEditor({ readOnly onRoleChange={() => undefined} onMachineTypeChange={() => undefined} + onPersistentVolumeChange={() => undefined} onMachineAction={null} onRemove={() => undefined} /> @@ -337,6 +390,7 @@ export function WorkspaceMembersEditor({ avatarUrl={orgMembers.find(({ id }) => id === draft.membershipId)?.avatarUrl ?? null} role={draft.role} machineTypeId={draft.machineTypeId} + persistentVolume={draft.persistentVolume} machine={null} machines={machines} defaultMachineTypeId={defaultMachineTypeId} @@ -346,6 +400,9 @@ export function WorkspaceMembersEditor({ at === index ? { ...current, role } : current))} onMachineTypeChange={(machineTypeId) => mode.onChange(mode.members.map((current, at) => at === index ? { ...current, machineTypeId } : current))} + onPersistentVolumeChange={(persistentVolume) => mode.onChange( + mode.members.map((current, at) => + at === index ? { ...current, persistentVolume } : current))} onMachineAction={null} onRemove={() => mode.onChange(mode.members.filter((_current, at) => at !== index))} /> @@ -357,6 +414,7 @@ export function WorkspaceMembersEditor({ avatarUrl={member.avatarUrl} role={member.role} machineTypeId={member.machine?.machineTypeId ?? WORKSPACE_DEFAULT_MACHINE_TYPE} + persistentVolume={volumeIntent[member.membershipId] ?? true} machine={member.machine} machines={machines} defaultMachineTypeId={defaultMachineTypeId} @@ -364,7 +422,13 @@ export function WorkspaceMembersEditor({ readOnly={readOnly} onRoleChange={(role) => mode.onRoleChange(member.membershipId, role)} onMachineTypeChange={(machineTypeId) => mode.onMachineTypeChange(member, machineTypeId)} - onMachineAction={readOnly ? null : (action) => mode.onMachineAction(member, action)} + onPersistentVolumeChange={(persistentVolume) => setVolumeIntent((current) => ({ + ...current, + [member.membershipId]: persistentVolume, + }))} + onMachineAction={readOnly ? null : (action) => mode.onMachineAction(member, action, { + persistentVolume: volumeIntent[member.membershipId] ?? true, + })} onRemove={() => mode.onRemove(member)} /> ))} diff --git a/packages/webapp/src/WorkspaceSettingsTab.tsx b/packages/webapp/src/WorkspaceSettingsTab.tsx index 86bd46cf..4580fc4b 100644 --- a/packages/webapp/src/WorkspaceSettingsTab.tsx +++ b/packages/webapp/src/WorkspaceSettingsTab.tsx @@ -136,7 +136,8 @@ function ReposEditor({ /** * The Settings tab of plan §6: name, default machine type, auto-provision, - * agent rules, repos, clone and delete. + * agent rules and repos. Clone and delete are workspace-wide verbs, so they + * sit in the dialog footer rather than at the bottom of this tab. * * Every field here is workspace-admin work (§3), so a member reads the values * and an admin edits them. The default machine type applies to machines @@ -152,8 +153,6 @@ export function WorkspaceSettingsTab({ onSave, onAddRepo, onRemoveRepo, - onClone, - onDelete, }: { client: AgentRulesApi; workspace: CloudWorkspaceModel; @@ -163,8 +162,6 @@ export function WorkspaceSettingsTab({ onSave: (input: UpdateWorkspaceRequest) => void; onAddRepo: (repo: string) => void; onRemoveRepo: (repo: string) => void; - onClone: (() => void) | null; - onDelete: (() => void) | null; }) { const [draft, setDraft] = useState(() => draftFor(workspace)); const changes = settingsChanges(workspace, draft); @@ -176,6 +173,7 @@ export function WorkspaceSettingsTab({ aria-label="Settings" className="workspace-details-settings" > +

    Workspace

    {canManage ? (
    {atCap && (

    @@ -229,11 +218,24 @@ export function TemplateRepoPicker({

    )}
    - - {value.length === 0 - ? 'No repositories selected' - : `${String(value.length)} ${value.length === 1 ? 'repository' : 'repositories'} selected`} - +
    + + {value.length === 0 + ? 'No repositories selected' + : `${String(value.length)} ${value.length === 1 ? 'repository' : 'repositories'} selected`} + + {/* Refresh repeats here, not only in the empty state. GitHub never + * returns to this page after an install, so the list cannot re-read + * itself. Without this, an account installed mid-session stays + * invisible until the whole screen is rebuilt. */} + +
    ); } diff --git a/packages/webapp/src/settings.css b/packages/webapp/src/settings.css index 97b18a81..b27881d5 100644 --- a/packages/webapp/src/settings.css +++ b/packages/webapp/src/settings.css @@ -127,6 +127,22 @@ font-weight: 650; } +.settings-nav-link { + display: block; + margin-top: 10px; + padding: 8px 9px; + border-top: 1px solid var(--rule); + color: var(--faint); + font: 12px/1.5 var(--font-ui); + text-decoration: none; +} + +.settings-nav-link:hover, +.settings-nav-link:focus-visible { + color: var(--ink); + outline: 0; +} + .settings-content { min-width: 0; min-height: 0; @@ -317,13 +333,6 @@ font: 12.5px/1.5 var(--font-ui); } - .settings-section-select .webapp-select-menu { - top: calc(100% + 5px); - bottom: auto; - width: 100%; - max-width: none; - } - .settings-section-select .webapp-select-chevron { transform: rotate(180deg); } diff --git a/packages/webapp/src/shell/ShellDialogs.tsx b/packages/webapp/src/shell/ShellDialogs.tsx index d3d2724a..fb5a3c8e 100644 --- a/packages/webapp/src/shell/ShellDialogs.tsx +++ b/packages/webapp/src/shell/ShellDialogs.tsx @@ -1,4 +1,4 @@ -import type { ListMachineTypesResponse, Volume } from '@blitzos/schema'; +import type { ListMachineTypesResponse } from '@blitzos/schema'; import type { ControlPlaneClient } from '../api'; import type { TenantMe } from '../api-adapter'; import { ConfirmationDialog } from '../ConfirmationDialog'; @@ -11,6 +11,7 @@ import { WorkspaceDetailsDialog, type WorkspaceDetailsTab, } from '../WorkspaceDetailsDialog'; +import { MyMachineDialog } from '../MyMachineDialog'; import type { CloudWorkspaceModel } from '../workspace-store'; /** The workspace this dialog stack is about to delete, and the name the @@ -31,15 +32,23 @@ export type ShellDialogsProps = { createWorkspaceBusy: boolean; createWorkspaceError: string | null; listMachineTypes: () => Promise; - listVolumes: () => Promise; /** The workspace a "new workspace from existing" copies, or null. */ cloneFromWorkspaceId: string | null; onCancelCreateWorkspace: () => void; onCreateWorkspace: (input: CreateWorkspaceDialogInput) => void; /** Which workspace the details dialog is about, and which tab it opens on. - * The rail's people icon opens Members; the ⋯ icon opens the default. */ - details: { workspaceId: string; tab: WorkspaceDetailsTab } | null; + * The rail's people icon opens Members; the ⋯ icon opens the default. + * `focusAddMember` is the tile menu's Invite, which lands on Members with + * the picker ready to type into. */ + details: { + workspaceId: string; + tab: WorkspaceDetailsTab; + focusAddMember?: boolean; + } | null; onCloseDetails: () => void; + /** The workspace whose "My machine" panel is open, or null. */ + machineWorkspaceId: string | null; + onCloseMachine: () => void; onCloneWorkspace: (workspaceId: string) => void; onRequestDeleteWorkspace: (workspaceId: string) => void; confirmation: WebAppConfirmation | null; @@ -60,12 +69,13 @@ export function ShellDialogs({ createWorkspaceBusy, createWorkspaceError, listMachineTypes, - listVolumes, cloneFromWorkspaceId, onCancelCreateWorkspace, onCreateWorkspace, details, onCloseDetails, + machineWorkspaceId, + onCloseMachine, onCloneWorkspace, onRequestDeleteWorkspace, confirmation, @@ -78,6 +88,9 @@ export function ShellDialogs({ const detailsWorkspace = details === null ? undefined : workspaces.find(({ id }) => id === details.workspaceId); + const machineWorkspace = machineWorkspaceId === null + ? undefined + : workspaces.find(({ id }) => id === machineWorkspaceId); // Workspace admin, or an org admin reaching in implicitly (§3): the wire // reports the second as a null stored role on a workspace they can open. const canManageDetails = detailsWorkspace?.myRole === 'admin' @@ -97,7 +110,6 @@ export function ShellDialogs({ saveComputeCredential={client.putComputeCredential} client={client} listMachineTypes={listMachineTypes} - listVolumes={listVolumes} cloneFromWorkspaceId={cloneFromWorkspaceId} cloneFromWorkspaceName={cloneSource?.title ?? null} viewerName={viewer?.identity.name || viewer?.identity.email || 'You'} @@ -111,6 +123,7 @@ export function ShellDialogs({ workspace={detailsWorkspace} listMachineTypes={listMachineTypes} initialTab={details.tab} + focusAddMember={details.focusAddMember ?? false} onClose={onCloseDetails} onClone={() => onCloneWorkspace(detailsWorkspace.id)} onDelete={canManageDetails @@ -118,6 +131,15 @@ export function ShellDialogs({ : null} /> )} + {machineWorkspace !== undefined && ( + + )} {confirmation && ( ; - pendingRequestCount: number; + livePorts: LivePort[]; + previewLinks: PreviewLink[]; drawerOpen: boolean; onSelectWorkspace: (workspaceId: string) => void; + onRenameWorkspace: (workspaceId: string, name: string) => void; + onOpenWorkspaceSettings: (workspaceId: string) => void; + onInviteToWorkspace: (workspaceId: string) => void; onCreateWorkspace: () => void; - onOpenPanel: (panel: WorkspaceDrawerSegment) => void; onSwitchOrg: (orgId: string) => void; onCreateOrg: () => void; onOpenDrive: () => void; onOpenSettings: () => void; onSelectSession: (sessionId: string) => void; onSpawnSession: (type: SpawnSessionType) => void; + onOpenPreview: (port: number) => void; + onOpenPreviewLink: (url: string, title: string) => void; onOpenWorkspaceMembers: (workspaceId: string) => void; onOpenWorkspaceDetails: (workspaceId: string) => void; + onOpenWorkspaceMachine: (workspaceId: string) => void; onCloseDrawer: () => void; }; @@ -45,20 +50,25 @@ export function ShellNav({ showRail, sessions, activeSessionId, - openPanels, - pendingRequestCount, + livePorts, + previewLinks, drawerOpen, onSelectWorkspace, + onRenameWorkspace, + onOpenWorkspaceSettings, + onInviteToWorkspace, onCreateWorkspace, - onOpenPanel, onSwitchOrg, onCreateOrg, onOpenDrive, onOpenSettings, onSelectSession, onSpawnSession, + onOpenPreview, + onOpenPreviewLink, onOpenWorkspaceMembers, onOpenWorkspaceDetails, + onOpenWorkspaceMachine, onCloseDrawer, }: ShellNavProps) { return ( @@ -68,12 +78,11 @@ export function ShellNav({ workspaces={workspaces} viewer={viewer} activeWorkspaceId={activeWorkspaceId} - openPanels={openPanels} - pendingRequestCount={pendingRequestCount} - surfacesEnabled={activeWorkspace !== undefined} onSelectWorkspace={onSelectWorkspace} + onRenameWorkspace={onRenameWorkspace} + onOpenWorkspaceSettings={onOpenWorkspaceSettings} + onInviteToWorkspace={onInviteToWorkspace} onCreateWorkspace={onCreateWorkspace} - onOpenPanel={onOpenPanel} onSwitchOrg={onSwitchOrg} onCreateOrg={onCreateOrg} onOpenDrive={onOpenDrive} @@ -85,10 +94,15 @@ export function ShellNav({ workspace={activeWorkspace} sessions={sessions} activeSessionId={activeSessionId} + livePorts={livePorts} + previewLinks={previewLinks} onSelectSession={onSelectSession} onSpawnSession={onSpawnSession} + onOpenPreview={onOpenPreview} + onOpenPreviewLink={onOpenPreviewLink} onOpenMembers={onOpenWorkspaceMembers} onOpenDetails={onOpenWorkspaceDetails} + onOpenMachine={onOpenWorkspaceMachine} /> )} diff --git a/packages/webapp/src/shell/StripIcons.tsx b/packages/webapp/src/shell/StripIcons.tsx index 4d905f26..addeffbe 100644 --- a/packages/webapp/src/shell/StripIcons.tsx +++ b/packages/webapp/src/shell/StripIcons.tsx @@ -24,29 +24,8 @@ function glyph(path: React.ReactNode, strokeWidth = 1.4) { export const PlusGlyph = glyph(, 1.7); -export const FilesGlyph = glyph( +/* Drive wears the folder outline the Drive surface already draws + * (files/DriveIcons.tsx `DriveGlyph`), on the strip's 16-grid. */ +export const DriveGlyph = glyph( , ); - -export const PortsGlyph = glyph( - <> - - - , -); - -export const ConnectionsGlyph = glyph( - <> - - - , -); - -export const ShareGlyph = glyph( - <> - - - - - , -); diff --git a/packages/webapp/src/shell/WorkspaceSessionRail.tsx b/packages/webapp/src/shell/WorkspaceSessionRail.tsx index c5404a4b..8ca30b51 100644 --- a/packages/webapp/src/shell/WorkspaceSessionRail.tsx +++ b/packages/webapp/src/shell/WorkspaceSessionRail.tsx @@ -1,47 +1,48 @@ import { useEffect, useState } from 'react'; -import { - SessionTypeIcon, - SPAWN_SESSION_LABELS, - type SpawnSessionType, -} from '../WebAppHeader'; -import { NATIVE_CHAT_ENABLED } from '../product-features'; +import { NewTabMenu, type SpawnSessionType } from '../NewTabMenu'; +import { SessionTypeIcon } from '../SessionTypeIcon'; +import type { LivePort, PreviewLink } from '../preview'; import type { CloudWorkspaceModel } from '../workspace-store'; +// The Drive page's own share icon, so one glyph means "share" everywhere. +import { BoxGlyph, ShareGlyph } from '../files/DriveIcons'; import type { DriveRailSession } from './rail-sessions'; -import { PlusGlyph, ShareGlyph } from './StripIcons'; - -/** The same list the tab strip's "+" offers, from the same source of truth, so - * the rail's pinned action and the strip's plus can never drift apart. */ -const SPAWN_SESSION_TYPES: SpawnSessionType[] = [ - ...(NATIVE_CHAT_ENABLED ? ['chat' as const] : []), - 'claude', - 'codex', - 'terminal', -]; +import { PlusGlyph } from './StripIcons'; export type WorkspaceSessionRailProps = { workspace: CloudWorkspaceModel | undefined; sessions: DriveRailSession[]; activeSessionId: string; + livePorts: LivePort[]; + previewLinks: PreviewLink[]; onSelectSession: (sessionId: string) => void; onSpawnSession: (type: SpawnSessionType) => void; + onOpenPreview: (port: number) => void; + onOpenPreviewLink: (url: string, title: string) => void; /** Membership IS sharing now (plans/MEMBER-MACHINES.md §3), so this opens * the details dialog on its Members tab. */ onOpenMembers: (workspaceId: string) => void; onOpenDetails: (workspaceId: string) => void; + /** The member's own machine in this workspace (§2.1). */ + onOpenMachine: (workspaceId: string) => void; }; /** Column two of the shell (plans/mockups/session-rail.html `#rail`): the - * workspace head, the pinned New session action, and one row per managed tab. + * workspace head, the pinned New tab action, and one row per managed tab. * The row is gutter · title · time, and never more — the time slot stays empty * until Build 2 gives a session a clock. */ export function WorkspaceSessionRail({ workspace, sessions, activeSessionId, + livePorts, + previewLinks, onSelectSession, onSpawnSession, + onOpenPreview, + onOpenPreviewLink, onOpenMembers, onOpenDetails, + onOpenMachine, }: WorkspaceSessionRailProps) { const [menuOpen, setMenuOpen] = useState(false); @@ -79,6 +80,15 @@ export function WorkspaceSessionRail({ onClick={() => onOpenMembers(workspace.id)} > )} + {workspace.canControl && ( + + )} {workspace.canControl && ( {menuOpen && ( - ))} - + { + setMenuOpen(false); + onSpawnSession(agent); + }} + onOpenPreview={(port) => { + setMenuOpen(false); + onOpenPreview(port); + }} + onOpenPreviewLink={(url, title) => { + setMenuOpen(false); + onOpenPreviewLink(url, title); + }} + /> )} diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx index c55ece5b..ac21e41a 100644 --- a/packages/webapp/src/shell/WorkspaceStrip.tsx +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -1,13 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import type { TenantMe } from '../api-adapter'; -import type { WorkspaceDrawerSegment } from '../storage'; import type { CloudWorkspaceModel } from '../workspace-store'; -import { - ConnectionsGlyph, - FilesGlyph, - PlusGlyph, - PortsGlyph, -} from './StripIcons'; +import { DriveGlyph, PlusGlyph } from './StripIcons'; +import { workspaceTileStyle } from './workspace-tile'; /** The tile legend: initials when the name has several words, otherwise its * first two letters. `design-team` reads DT and `engineering` reads EN, as the @@ -19,18 +14,6 @@ export function workspaceCode(title: string): string { return words.slice(0, 3).map((word) => word[0]!).join('').toUpperCase(); } -/** The surfaces the strip can focus. They are the same three panels the right - * icon strip toggles, under the same names, so one panel never has two. */ -const SURFACES: Array<{ - id: WorkspaceDrawerSegment; - label: string; - Glyph: (props: { className?: string }) => React.ReactElement; -}> = [ - { id: 'files', label: 'Files', Glyph: FilesGlyph }, - { id: 'previews', label: 'teenyapps', Glyph: PortsGlyph }, - { id: 'connections', label: 'Connections', Glyph: ConnectionsGlyph }, -]; - function stateLabel(workspace: CloudWorkspaceModel): string { if (workspace.lifecycleStatus === 'creating') return 'creating'; if (workspace.lifecycleStatus === 'error') return 'failed'; @@ -38,18 +21,26 @@ function stateLabel(workspace: CloudWorkspaceModel): string { return workspace.lifecycleStatus; } +/** The context menu's own geometry, in viewport coordinates, and the + * workspace it belongs to. Clamped when it opens, exactly as the tab strip's + * menu is. */ +type TileMenu = { workspaceId: string; left: number; top: number }; + +const TILE_MENU_WIDTH = 190; +const TILE_MENU_HEIGHT = 140; + export type WorkspaceStripProps = { workspaces: CloudWorkspaceModel[]; viewer: TenantMe | null; activeWorkspaceId: string | null; - /** Panels already open in the work area; the strip rings the matching icon. */ - openPanels: ReadonlySet; - pendingRequestCount: number; - /** False on Drive and settings, where there is no box to open a panel on. */ - surfacesEnabled: boolean; onSelectWorkspace: (workspaceId: string) => void; + /** The three verbs the tile's context menu offers. Rename writes the name + * through the same PATCH the settings tab uses; the other two open the + * details dialog on the tab that answers them. */ + onRenameWorkspace: (workspaceId: string, name: string) => void; + onOpenWorkspaceSettings: (workspaceId: string) => void; + onInviteToWorkspace: (workspaceId: string) => void; onCreateWorkspace: () => void; - onOpenPanel: (panel: WorkspaceDrawerSegment) => void; onSwitchOrg: (orgId: string) => void; onCreateOrg: () => void; onOpenDrive: () => void; @@ -58,18 +49,18 @@ export type WorkspaceStripProps = { }; /** Column one of the shell (plans/mockups/session-rail.html `#strip`): the org - * mark, one tile per workspace, the create tile, the workspace surfaces, and - * the account menu on the bottom edge. */ + * mark, one tile per workspace, the create tile, Drive, and the avatar on the + * bottom edge, which goes straight to settings. The workspace panels are the + * right icon strip's job. */ export function WorkspaceStrip({ workspaces, viewer, activeWorkspaceId, - openPanels, - pendingRequestCount, - surfacesEnabled, onSelectWorkspace, + onRenameWorkspace, + onOpenWorkspaceSettings, + onInviteToWorkspace, onCreateWorkspace, - onOpenPanel, onSwitchOrg, onCreateOrg, onOpenDrive, @@ -77,20 +68,66 @@ export function WorkspaceStrip({ onCloseDrawer, }: WorkspaceStripProps) { const [orgMenuOpen, setOrgMenuOpen] = useState(false); - const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const [tileMenu, setTileMenu] = useState(null); + const [renaming, setRenaming] = useState< + { workspaceId: string; value: string; left: number; top: number } | null + >(null); + const renameInput = useRef(null); const orgLabel = viewer?.org.name || viewer?.org.slug || 'Organization'; const userLabel = viewer?.identity.name || viewer?.identity.email || 'BlitzOS'; useEffect(() => { - if (!orgMenuOpen && !accountMenuOpen) return; + if (!orgMenuOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOrgMenuOpen(false); + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [orgMenuOpen]); + + useEffect(() => { + if (tileMenu === null && renaming === null) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; - setOrgMenuOpen(false); - setAccountMenuOpen(false); + setTileMenu(null); + setRenaming(null); }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [accountMenuOpen, orgMenuOpen]); + }, [renaming, tileMenu]); + + useEffect(() => { + renameInput.current?.focus(); + renameInput.current?.select(); + }, [renaming?.workspaceId]); + + // Workspace admin, or an org admin reaching in implicitly (§3): the wire + // reports the second as a null stored role on a workspace they can open. + const menuWorkspace = tileMenu === null + ? undefined + : workspaces.find(({ id }) => id === tileMenu.workspaceId); + const canManage = menuWorkspace?.myRole === 'admin' || menuWorkspace?.myRole === null; + + const openTileMenu = (event: ReactMouseEvent, workspace: CloudWorkspaceModel) => { + event.preventDefault(); + setRenaming(null); + setOrgMenuOpen(false); + setTileMenu({ + workspaceId: workspace.id, + left: Math.max(8, Math.min(event.clientX, window.innerWidth - TILE_MENU_WIDTH)), + top: Math.max(8, Math.min(event.clientY, window.innerHeight - TILE_MENU_HEIGHT)), + }); + }; + + const finishRename = () => { + if (renaming === null) return; + const name = renaming.value.trim(); + const current = workspaces.find(({ id }) => id === renaming.workspaceId); + setRenaming(null); + if (name !== '' && current !== undefined && name !== current.title) { + onRenameWorkspace(renaming.workspaceId, name); + } + }; return ( ); diff --git a/packages/webapp/src/shell/workspace-tile.ts b/packages/webapp/src/shell/workspace-tile.ts new file mode 100644 index 00000000..88142d9c --- /dev/null +++ b/packages/webapp/src/shell/workspace-tile.ts @@ -0,0 +1,114 @@ +/** Every workspace tile in the strip wears a gradient derived from its id, so + * two tiles are told apart by colour before their two-letter code is read. The + * derivation is pure and deterministic: the same id always paints the same + * tile, on every device and every reload, with nothing stored anywhere. */ + +type Rgb = { red: number; green: number; blue: number }; + +export type WorkspaceTileStyle = { + /** A CSS `background` value: the two-stop gradient. */ + background: string; + /** The initials' colour, picked so the tile clears WCAG AA (4.5:1). */ + color: string; +}; + +/** The second stop is a short walk around the wheel: far enough to read as a + * gradient, near enough that the tile stays one colour rather than two. */ +const HUE_SPREAD = 40; +const SATURATION = 0.58; +const LIGHTNESS = 0.46; + +/** Against a background of this luminance neither white nor black reaches + * 4.5:1 with any margin — 4.58:1 is the best a pure black or white can do, and + * these near-black and near-white inks do worse. Tiles that land in the band + * are darkened out of it, which keeps the near-white ink well past AA. */ +const AMBIGUOUS_LUMINANCE_MIN = 0.16; +const AMBIGUOUS_LUMINANCE_MAX = 0.26; +const DARKEN_FACTOR = 0.66; + +const INK_LIGHT: Rgb = { red: 248, green: 250, blue: 252 }; +const INK_DARK: Rgb = { red: 11, green: 16, blue: 32 }; + +/** FNV-1a, 32-bit. Chosen for spreading short ids across the wheel, not for + * any security property. */ +function hashWorkspaceId(workspaceId: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < workspaceId.length; index += 1) { + hash = Math.imul(hash ^ workspaceId.charCodeAt(index), 0x01000193) >>> 0; + } + return hash; +} + +function hueToRgb(hue: number): Rgb { + const chroma = (1 - Math.abs(2 * LIGHTNESS - 1)) * SATURATION; + const sector = hue / 60; + const second = chroma * (1 - Math.abs((sector % 2) - 1)); + const base = LIGHTNESS - chroma / 2; + const channels: [number, number, number] = sector < 1 ? [chroma, second, 0] + : sector < 2 ? [second, chroma, 0] + : sector < 3 ? [0, chroma, second] + : sector < 4 ? [0, second, chroma] + : sector < 5 ? [second, 0, chroma] + : [chroma, 0, second]; + return { + red: Math.round((channels[0] + base) * 255), + green: Math.round((channels[1] + base) * 255), + blue: Math.round((channels[2] + base) * 255), + }; +} + +function darken(color: Rgb): Rgb { + return { + red: Math.round(color.red * DARKEN_FACTOR), + green: Math.round(color.green * DARKEN_FACTOR), + blue: Math.round(color.blue * DARKEN_FACTOR), + }; +} + +function channelLuminance(value: number): number { + const unit = value / 255; + return unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4; +} + +/** WCAG relative luminance, 0 (black) to 1 (white). */ +function relativeLuminance(color: Rgb): number { + return 0.2126 * channelLuminance(color.red) + + 0.7152 * channelLuminance(color.green) + + 0.0722 * channelLuminance(color.blue); +} + +/** WCAG contrast ratio between two relative luminances. */ +function contrastRatio(one: number, other: number): number { + const lighter = Math.max(one, other); + const darker = Math.min(one, other); + return (lighter + 0.05) / (darker + 0.05); +} + +function css(color: Rgb): string { + return `rgb(${String(color.red)} ${String(color.green)} ${String(color.blue)})`; +} + +/** The two gradient stops. The ink has to read over both, so the pair's + * average luminance is what the ink choice is made against. */ +function workspaceTileStops(workspaceId: string): [Rgb, Rgb] { + const hue = hashWorkspaceId(workspaceId) % 360; + const start = hueToRgb(hue); + const end = hueToRgb((hue + HUE_SPREAD) % 360); + const luminance = (relativeLuminance(start) + relativeLuminance(end)) / 2; + if (luminance < AMBIGUOUS_LUMINANCE_MIN || luminance > AMBIGUOUS_LUMINANCE_MAX) { + return [start, end]; + } + return [darken(start), darken(end)]; +} + +/** The inline style for one workspace tile. */ +export function workspaceTileStyle(workspaceId: string): WorkspaceTileStyle { + const [start, end] = workspaceTileStops(workspaceId); + const luminance = (relativeLuminance(start) + relativeLuminance(end)) / 2; + const light = contrastRatio(relativeLuminance(INK_LIGHT), luminance); + const dark = contrastRatio(relativeLuminance(INK_DARK), luminance); + return { + background: `linear-gradient(135deg, ${css(start)} 0%, ${css(end)} 100%)`, + color: css(light >= dark ? INK_LIGHT : INK_DARK), + }; +} diff --git a/packages/webapp/src/strip-rail.css b/packages/webapp/src/strip-rail.css index 2ee87a29..fd09aaf2 100644 --- a/packages/webapp/src/strip-rail.css +++ b/packages/webapp/src/strip-rail.css @@ -132,26 +132,9 @@ cursor: pointer; } -.shell-ic:hover:not(:disabled) { color: var(--ink); background: var(--hover); } -.shell-ic--on { color: var(--ink); background: var(--selected); } -.shell-ic:disabled { opacity: .45; cursor: default; } +.shell-ic:hover { color: var(--ink); background: var(--hover); } .shell-ic__glyph { width: 15px; height: 15px; } -.shell-ic__count { - position: absolute; - top: 2px; - right: 2px; - display: grid; - min-width: 13px; - height: 13px; - padding: 0 3px; - place-items: center; - border-radius: 7px; - color: var(--paper); - background: var(--accent); - font: 700 9px/1 var(--font-ui); -} - .shell-strip__account { position: relative; flex: none; } .shell-av { @@ -171,8 +154,8 @@ .shell-av__photo { width: 100%; height: 100%; object-fit: cover; } -/* The org and account popovers reuse the shell's menu skin; only the anchor - changes, because the strip is 48px wide and they open beside it. */ +/* The org popover reuses the shell's menu skin; only the anchor changes, + because the strip is 48px wide and it opens beside it. */ .shell-strip__menu { top: 0; right: auto; @@ -180,8 +163,6 @@ width: 220px; } -.shell-strip__menu--account { top: auto; bottom: 0; } - /* --------------------------------------------------------------- column 2 */ .shell-rail { display: flex; @@ -394,3 +375,21 @@ @media (prefers-reduced-motion: reduce) { .shell-nav { transition: none; } } + +/* The tile's context menu, in the tab strip's own menu chrome + (.webapp-session-menu). Only the rename field is new: the tile is two + letters wide, so the name is edited in the popover the menu opened at. */ +.shell-wmenu { font: 13px/1.3 var(--font-ui); } + +.shell-wmenu--rename { width: 220px; } + +.shell-wmenu--rename input { + width: 100%; + min-height: 32px; + padding: 0 9px; + border: 1px solid var(--rule); + border-radius: var(--r-item); + color: var(--ink); + background: var(--paper); + font: 13px/1.3 var(--font-ui); +} diff --git a/packages/webapp/src/webapp-select.css b/packages/webapp/src/webapp-select.css index 14a3d368..8292391d 100644 --- a/packages/webapp/src/webapp-select.css +++ b/packages/webapp/src/webapp-select.css @@ -45,11 +45,13 @@ font-size: 10px; } +/* The popover takes the top stacking position, and takes it once: it is + fixed, so no scroll container between it and the page clips it and nothing + inside a dialog paints over it. WebAppSelectMenu writes `left` and one of + `top`/`bottom` from the trigger's viewport rect. */ .webapp-select-menu { - position: absolute; - z-index: 40; - bottom: calc(100% + 6px); - left: 0; + position: fixed; + z-index: 900; width: max-content; min-width: 220px; max-width: min(320px, calc(100vw - 24px)); diff --git a/packages/webapp/src/workspace-details-dialog.css b/packages/webapp/src/workspace-details-dialog.css index 7d2d9f38..e1bd065f 100644 --- a/packages/webapp/src/workspace-details-dialog.css +++ b/packages/webapp/src/workspace-details-dialog.css @@ -11,11 +11,14 @@ font-family: var(--font-ui); } -.workspace-details-header { +.workspace-details-header, +.workspace-details-footer { display: flex; flex: none; align-items: center; - justify-content: space-between; padding: 20px 18px 6px 22px; } +} + +.workspace-details-header { justify-content: space-between; padding: 20px 18px 6px 22px; } .workspace-details-header h1 { min-width: 0; margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em; } .workspace-details-header h1 em { font-style: normal; } .workspace-details-header button { flex: none; width: 34px; height: 34px; border: 0; border-radius: 50%; color: var(--faint); background: transparent; font-size: 20px; } @@ -39,6 +42,7 @@ .workspace-details-members, .workspace-details-credentials, .workspace-details-settings { min-width: 0; min-height: 240px; } +.workspace-details-members h2, .workspace-details-credentials h2, .workspace-details-settings h2 { margin: 0 0 9px; color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } @@ -46,13 +50,18 @@ .workspace-members-rows { border-top: 1px solid var(--rule); } .workspace-members-empty { margin: 14px 0; color: var(--faint); font-size: 11px; } -.workspace-member-row { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto auto auto auto 28px; align-items: center; gap: 8px; min-height: 52px; border-bottom: 1px solid color-mix(in oklab, var(--rule) 60%, transparent); } +.workspace-member-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto auto auto auto auto 28px; align-items: center; gap: 10px; min-height: 48px; border-bottom: 1px solid color-mix(in oklab, var(--rule) 60%, transparent); } .workspace-member-name { display: grid; min-width: 0; gap: 2px; } .workspace-member-name strong, .workspace-member-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-member-name strong { font-size: 11px; font-weight: 550; } .workspace-member-name small { color: var(--faint); font-size: 10px; } .workspace-member-role-static { color: var(--faint); font-size: 10px; text-transform: capitalize; } +/* The per-member disk choice, next to the type select it applies to. A row + * whose machine exists shows the disk it has, disabled. */ +.workspace-member-volume { display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 10px; white-space: nowrap; } +.workspace-member-volume input { width: auto; margin: 0; } +.workspace-member-volume input:disabled + span { opacity: .55; } .workspace-member-remove { width: 24px; height: 24px; border: 0; border-radius: 50%; color: var(--faint); background: transparent; font-size: 15px; } .workspace-member-remove:hover, .workspace-member-remove:focus-visible { color: var(--ansi-red); background: var(--hover); } @@ -89,7 +98,7 @@ .workspace-repo-add { display: grid; gap: 10px; margin-top: 14px; } .workspace-details-note { margin: 0 0 14px; color: var(--faint); font-size: 11px; } -.workspace-details-settings-actions { display: flex; gap: 10px; margin-top: 18px; } +.workspace-details-footer { justify-content: flex-start; gap: 10px; padding: 14px 18px 18px; } .workspace-details-status, .workspace-details-error { margin: 0 0 14px; font-size: 11px; } @@ -104,8 +113,8 @@ .workspace-details-dialog { max-height: calc(100dvh - 24px); } /* The row keeps the avatar, the name and the remove control; the selects * and the chip wrap under them rather than squeeze. */ - .workspace-member-row { grid-template-columns: 32px minmax(0, 1fr) 28px; } - .workspace-details-settings-actions { flex-direction: column; } + .workspace-member-row { grid-template-columns: 28px minmax(0, 1fr) 28px; } + .workspace-details-footer { flex-direction: column; } .workspace-details-delete { width: 100%; } } @@ -120,3 +129,13 @@ /* No machine at all: the member is a viewer, or the workspace does not * auto-provision and their role has not been written since. */ .machine-chip--none { border-style: dashed; } + +/* "My machine" wears the same chrome with no tab row, so the body starts + under the header rather than under a rule. */ +.my-machine-dialog { width: min(520px, 100%); } +.my-machine-dialog .workspace-details-body { padding-top: 14px; } +.my-machine-panel { min-width: 0; min-height: 240px; } +.my-machine-panel h2 { margin: 18px 0 9px; color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.my-machine-panel h2:first-child { margin-top: 0; } +.my-machine-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.my-machine-actions button:disabled { cursor: not-allowed; opacity: .5; } diff --git a/packages/webapp/test/WorkspaceDetailsDialog.test.tsx b/packages/webapp/test/WorkspaceDetailsDialog.test.tsx index 26db2106..a9f6faf7 100644 --- a/packages/webapp/test/WorkspaceDetailsDialog.test.tsx +++ b/packages/webapp/test/WorkspaceDetailsDialog.test.tsx @@ -170,6 +170,78 @@ describe('WorkspaceDetailsDialog', () => { await view.unmount(); }); + it('provisions without a volume when the row turns the toggle off', async () => { + const provisionMemberMachine = vi.fn().mockResolvedValue({ member: grace }); + const view = await render(dialog({ + client: client({ provisionMemberMachine }), + workspace: { ...workspace, members: [ada, { ...grace, role: 'member' }] }, + })); + await settle(); + + // Ada's machine already holds a volume, so her row reports the disk that + // exists rather than offering a choice this route cannot make. + const settled = view.container.querySelector( + '[aria-label="Persistent volume for Ada Owner"]', + ); + expect(settled?.checked).toBe(true); + expect(settled?.disabled).toBe(true); + + const toggle = view.container.querySelector( + '[aria-label="Persistent volume for Grace Viewer"]', + ); + expect(toggle?.checked).toBe(true); + await act(async () => toggle?.click()); + + const menu = view.container.querySelector( + '[aria-label="Machine actions for Grace Viewer"]', + ); + await act(async () => menu?.click()); + await act(async () => view.container.querySelector('[role="option"]')?.click()); + + expect(provisionMemberMachine).toHaveBeenCalledWith( + workspace.id, + grace.membershipId, + { persistentVolume: false }, + ); + await view.unmount(); + }); + + it('anchors every row popover to the viewport, so the dialog cannot clip it', async () => { + const view = await render(dialog({ + workspace: { ...workspace, members: [ada, { ...grace, role: 'member' }] }, + })); + await settle(); + + // The three the report named: the role listbox, the machine-type listbox + // and the lifecycle menu. Each sits inside `.workspace-details-body`, + // which scrolls, so an absolutely positioned popover was clipped by it. + // Ada is the workspace owner, so her role is a fact and not a control. + const labels = [ + 'Role for Grace Viewer', + 'Machine type for Ada Owner', + 'Machine actions for Ada Owner', + ]; + for (const label of labels) { + const trigger = view.container.querySelector(`[aria-label="${label}"]`); + if (trigger === null) throw new Error(`no trigger for ${label}`); + // A row far enough down the viewport that the popover opens upward. + trigger.getBoundingClientRect = () => ({ + x: 300, y: 500, left: 300, top: 500, right: 420, bottom: 530, + width: 120, height: 30, toJSON: () => ({}), + }); + await act(async () => trigger.click()); + const menu = view.container.querySelector(`[role="listbox"][aria-label="${label}"]`); + if (menu === null) throw new Error(`no popover for ${label}`); + expect(menu.style.left).toBe('300px'); + // Anchored above the trigger, in viewport coordinates rather than in the + // scrolling body's. + expect(menu.style.bottom).toBe(`${String(window.innerHeight - 500 + 6)}px`); + expect(menu.style.top).toBe(''); + await act(async () => trigger.click()); + } + await view.unmount(); + }); + it('lists credential names, never a value, and revokes one', async () => { const revokeWorkspaceCredential = vi.fn().mockResolvedValue(undefined); const view = await render(dialog({ client: client({ revokeWorkspaceCredential }) })); @@ -191,17 +263,24 @@ describe('WorkspaceDetailsDialog', () => { await view.unmount(); }); - it('offers clone and delete from Settings, and names the default machine type', async () => { + it('offers clone and delete from the footer, and names the default machine type', async () => { const onClone = vi.fn(); const onDelete = vi.fn(); const view = await render(dialog({ onClone, onDelete })); await settle(); - await act(async () => tab(view.container, 'Settings')?.click()); + // The pre-#106 chrome: the header names the workspace and the two + // workspace-wide verbs live in the footer, under every tab. + expect(view.container.querySelector('.workspace-details-header h1')?.textContent) + .toBe('Workspace details “Details test”'); + const footer = view.container.querySelector('.workspace-details-footer'); + expect(footer).not.toBeNull(); + + await act(async () => tab(view.container, 'Settings')?.click()); expect(view.container.textContent).toContain('Shared x86'); expect(view.container.textContent).toContain('Applies to new machines'); - const buttons = [...view.container.querySelectorAll('button')]; + const buttons = [...footer!.querySelectorAll('button')]; await act(async () => buttons.find((b) => b.textContent === 'New workspace from this one')?.click()); await act(async () => buttons.find((b) => b.textContent === 'Delete workspace')?.click()); expect(onClone).toHaveBeenCalledOnce(); @@ -316,10 +395,15 @@ describe('WorkspaceSessionRail', () => { workspace={workspace} sessions={[]} activeSessionId="" + livePorts={[]} + previewLinks={[]} onSelectSession={() => undefined} onSpawnSession={() => undefined} + onOpenPreview={() => undefined} + onOpenPreviewLink={() => undefined} onOpenMembers={onOpenMembers} onOpenDetails={onOpenDetails} + onOpenMachine={() => undefined} />, ); @@ -342,10 +426,15 @@ describe('WorkspaceSessionRail', () => { workspace={{ ...workspace, accessRole: 'editor', shared: true }} sessions={[]} activeSessionId="" + livePorts={[]} + previewLinks={[]} onSelectSession={() => undefined} onSpawnSession={() => undefined} + onOpenPreview={() => undefined} + onOpenPreviewLink={() => undefined} onOpenMembers={onOpenMembers} onOpenDetails={onOpenDetails} + onOpenMachine={() => undefined} />, )); expect(view.container.querySelector('button[aria-label="Members of Details test"]')).toBeNull(); @@ -354,6 +443,40 @@ describe('WorkspaceSessionRail', () => { )).not.toBeNull(); await view.unmount(); }); + + it('opens the same New tab menu the tab strip serves, live ports and all', async () => { + const onSpawnSession = vi.fn(); + const onOpenPreview = vi.fn(); + const view = await render( + undefined} + onSpawnSession={onSpawnSession} + onOpenPreview={onOpenPreview} + onOpenPreviewLink={() => undefined} + onOpenMembers={() => undefined} + onOpenDetails={() => undefined} + onOpenMachine={() => undefined} + />, + ); + + const pinned = view.container.querySelector('button[aria-label="New tab"]'); + expect(pinned?.textContent).toContain('New tab'); + await act(async () => pinned?.click()); + const items = [...view.container.querySelectorAll( + '.webapp-agent-menu.shell-newmenu [role="menuitem"]', + )]; + expect(items.map((item) => item.textContent?.trim())) + .toEqual(['Claude', 'Codex', 'Terminal', ':3000vite']); + await act(async () => items[3]?.click()); + expect(onOpenPreview).toHaveBeenCalledWith(3000); + expect(view.container.querySelector('.shell-newmenu')).toBeNull(); + await view.unmount(); + }); }); describe('machineActionsFor', () => { diff --git a/packages/webapp/test/compute-credentials.test.tsx b/packages/webapp/test/compute-credentials.test.tsx index e30d4c3e..d2746489 100644 --- a/packages/webapp/test/compute-credentials.test.tsx +++ b/packages/webapp/test/compute-credentials.test.tsx @@ -71,6 +71,12 @@ describe('compute credential settings', () => { , ); expect(memberView.container.textContent).not.toContain('Compute'); + // The Discord link left the strip's account menu; settings navigation + // carries it now. + const discord = memberView.container.querySelector( + '.settings-side a[href^="https://discord.gg/"]', + ); + expect(discord?.textContent).toBe('Ask us on Discord'); await memberView.unmount(); const adminView = await render( diff --git a/packages/webapp/test/create-workspace.test.tsx b/packages/webapp/test/create-workspace.test.tsx index 90078261..b203ca57 100644 --- a/packages/webapp/test/create-workspace.test.tsx +++ b/packages/webapp/test/create-workspace.test.tsx @@ -81,7 +81,6 @@ describe("create workspace dialog", () => { orgName="acme" client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -116,7 +115,7 @@ describe("create workspace dialog", () => { await view.unmount(); }); - it("includes optional SSH and volume fields only when selected", async () => { + it("includes the optional SSH key only when one is typed", async () => { const submit = vi.fn(); const view = await render( { orgName="acme" client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => [{ - id: "vol-1", - name: "home", - sizeGb: 50, - location: "fsn1", - status: "available", - attachedTo: null, - }]} onCancel={() => undefined} onSubmit={submit} />, @@ -140,12 +131,9 @@ describe("create workspace dialog", () => { await settle(); const key = view.container.querySelector('textarea[name="sshPublicKey"]')!; - const volume = view.container.querySelector('select[name="volumeId"]')!; await act(async () => { key.value = "ssh-ed25519 AAAA operator@example"; key.dispatchEvent(new Event("input", { bubbles: true })); - volume.value = "vol-1"; - volume.dispatchEvent(new Event("change", { bubbles: true })); view.container.querySelector("form")?.dispatchEvent( new Event("submit", { bubbles: true, cancelable: true }), ); @@ -154,15 +142,13 @@ describe("create workspace dialog", () => { expect(submit).toHaveBeenCalledWith({ machineTypeId: "cx23@fsn1", sshPublicKey: "ssh-ed25519 AAAA operator@example", - volumeId: "vol-1", }); const completeRequest = submit.mock.calls[0]?.[0]; - expect(Object.keys(completeRequest).sort()).toEqual(["machineTypeId", "sshPublicKey", "volumeId"]) - expect("sshPublicKey" in completeRequest).toBe(true); - expect("volumeId" in completeRequest).toBe(true); - expect(JSON.stringify(completeRequest)).toBe( - '{"machineTypeId":"cx23@fsn1","sshPublicKey":"ssh-ed25519 AAAA operator@example","volumeId":"vol-1"}', - ); + expect(Object.keys(completeRequest).sort()).toEqual(["machineTypeId", "sshPublicKey"]); + // The workspace no longer picks a volume: each member's row does, and the + // `volumeId` field stays on the wire for a recreate alone. + expect("volumeId" in completeRequest).toBe(false); + expect(view.container.querySelector('select[name="volumeId"]')).toBeNull(); await view.unmount(); }); @@ -180,7 +166,6 @@ describe("create workspace dialog", () => { { providerId: "microvm", error: "no microVM hosts are reachable" }, ], })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -210,7 +195,6 @@ describe("create workspace dialog", () => { { providerId: "hetzner", error: "Hetzner API request failed with status 403" }, ], })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -236,7 +220,6 @@ describe("create workspace dialog", () => { orgName="acme" client={rulesClient()} listMachineTypes={async () => ({ machineTypes: [], failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -248,45 +231,6 @@ describe("create workspace dialog", () => { await view.unmount(); }); - it("disables volume selection for a provider without volume support", async () => { - const submit = vi.fn(); - const view = await render( - ({ machineTypes: machines, failures: [] })} - listVolumes={async () => [{ - id: "vol-1", - name: "home", - sizeGb: 50, - location: "fsn1", - status: "available", - attachedTo: null, - }]} - onCancel={() => undefined} - onSubmit={submit} - />, - ); - await settle(); - - const microvm = view.container.querySelector( - 'input[value="mv-2c2g@lab"]', - ); - await act(async () => { - microvm?.click(); - }); - const volume = view.container.querySelector('select[name="volumeId"]'); - - expect(volume?.disabled).toBe(true); - expect(view.container.textContent).toContain( - "Volumes are not supported by this machine provider.", - ); - await view.unmount(); - }); - - it("carries a repo selection with no template through the connect round trip", async () => { const client = rulesClient(); client.listGithubRepositories = vi.fn(async () => { @@ -299,7 +243,6 @@ describe("create workspace dialog", () => { orgName="acme" client={client} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={vi.fn()} />, @@ -338,7 +281,6 @@ describe("create workspace dialog", () => { orgName="acme" client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -371,7 +313,6 @@ describe("create workspace dialog", () => { orgName="acme" client={rulesClient([BUILT_IN_RULE, orgRule])} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -439,7 +380,6 @@ describe("create workspace dialog", () => { saveComputeCredential={saveComputeCredential} client={rulesClient()} listMachineTypes={listMachineTypes} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -485,7 +425,6 @@ describe("create workspace dialog", () => { failures: [], providerStatuses: [{ providerId: 'aws', access: 'credential-required' }], })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -511,7 +450,6 @@ describe("create workspace dialog", () => { viewerName="Ada Park" client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -555,6 +493,54 @@ describe("create workspace dialog", () => { await view.unmount(); }); + it("sends a member's persistent-volume refusal, and nothing when it stays on", async () => { + const submit = vi.fn(); + const view = await render( + ({ machineTypes: machines, failures: [] })} + onCancel={() => undefined} + onSubmit={submit} + />, + ); + await settle(); + await settle(); + + const search = view.container.querySelector('[aria-label="Add people"]')!; + await act(async () => { + search.focus(); + search.dispatchEvent(new Event("focus", { bubbles: true })); + }); + await act(async () => [...view.container.querySelectorAll(".drive-suggestion")] + .find((button) => button.textContent?.includes("Nia Newcomer"))?.click()); + + const toggle = view.container.querySelector( + '[aria-label="Persistent volume for Nia Newcomer"]', + )!; + // Default ON: every member keeps their disk unless somebody says otherwise, + // and the other member tests show that default travelling as no field. + expect(toggle.checked).toBe(true); + await act(async () => toggle.click()); + await act(async () => { + view.container.querySelector("form")?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }); + expect(submit).toHaveBeenCalledWith(expect.objectContaining({ + members: [{ + membershipId: "membership-2", + role: "member", + persistentVolume: false, + }], + })); + await view.unmount(); + }); + it("hides the machine type on a viewer row and omits an unchosen type from the body", async () => { const submit = vi.fn(); const view = await render( @@ -565,7 +551,6 @@ describe("create workspace dialog", () => { admin client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -614,7 +599,6 @@ describe("create workspace dialog", () => { admin client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, @@ -658,7 +642,6 @@ describe("create workspace dialog", () => { admin={false} client={rulesClient()} listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={() => undefined} />, @@ -681,7 +664,6 @@ describe("create workspace dialog", () => { cloneFromWorkspaceId="workspace-source" cloneFromWorkspaceName="engineering" listMachineTypes={async () => ({ machineTypes: machines, failures: [] })} - listVolumes={async () => []} onCancel={() => undefined} onSubmit={submit} />, diff --git a/packages/webapp/test/my-machine.test.tsx b/packages/webapp/test/my-machine.test.tsx new file mode 100644 index 00000000..bfdd9a9c --- /dev/null +++ b/packages/webapp/test/my-machine.test.tsx @@ -0,0 +1,177 @@ +import { act } from 'react'; +import type { MachineType, WorkspaceMemberView } from '@blitzos/schema'; +import { describe, expect, it, vi } from 'vitest'; +import type { ControlPlaneClient } from '../src/api.js'; +import { MyMachineDialog } from '../src/MyMachineDialog.js'; +import { WorkspaceSessionRail } from '../src/shell/WorkspaceSessionRail.js'; +import { render, settle } from './dom.js'; +import { workspaceModelFixture } from './workspace-fixtures.js'; + +const machineTypes: MachineType[] = [ + { + id: 'cx23@fsn1', + providerId: 'hetzner', + supportsVolumes: true, + name: 'Shared x86', + cpuCores: 2, + memGb: 4, + diskGb: 40, + arch: 'x86', + location: 'fsn1', + monthlyPrice: { amount: 6.49, currency: 'USD' }, + }, +]; + +const ada: WorkspaceMemberView = { + membershipId: 'membership-1', + name: 'Ada Owner', + avatarUrl: null, + role: 'admin', + machine: null, +}; + +const me: WorkspaceMemberView = { + membershipId: 'membership-2', + name: 'Mo Member', + avatarUrl: null, + role: 'member', + machine: { + id: 'machine-mo', + state: 'running', + machineTypeId: 'cx23@fsn1', + volumeId: 'volume-one', + membershipId: 'membership-2', + error: null, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }, +}; + +const workspace = workspaceModelFixture({ + title: 'design-team', + members: [ada, me], + myRole: 'member', +}); + +function client(overrides: Partial = {}): ControlPlaneClient { + // SAFETY: the dialog reaches only for the writes each test names. + return { ...overrides } as unknown as ControlPlaneClient; +} + +function dialog(overrides: Partial[0]> = {}) { + return ( + ({ machineTypes, failures: [] })} + onClose={() => undefined} + {...overrides} + /> + ); +} + +function buttons(container: HTMLElement): HTMLButtonElement[] { + return [...container.querySelectorAll('.my-machine-actions button')]; +} + +describe('MyMachineDialog', () => { + it('describes the member’s own machine and stops it', async () => { + const stopMachine = vi.fn().mockResolvedValue({ machine: me.machine }); + const view = await render(dialog({ client: client({ stopMachine }) })); + await settle(); + + // One view, no tab row: there is one thing to read here. + expect(view.container.querySelector('.workspace-details-tabs')).toBeNull(); + expect(view.container.textContent).toContain('Shared x86'); + expect(view.container.textContent).toContain('2 vCPU'); + expect(view.container.textContent).toContain('4 GB'); + expect(view.container.textContent).toContain('$6.49/mo'); + expect(view.container.textContent).toContain('Attached'); + + const stop = buttons(view.container).find((button) => button.textContent === 'Stop'); + expect(stop?.disabled).toBe(false); + await act(async () => stop?.click()); + expect(stopMachine).toHaveBeenCalledWith('machine-mo'); + await view.unmount(); + }); + + it('names the admins to ask for a verb a member may not run', async () => { + const view = await render(dialog()); + await settle(); + + const recreate = buttons(view.container).find((button) => button.textContent === 'Recreate'); + const destroy = buttons(view.container).find((button) => button.textContent === 'Destroy'); + expect(recreate?.disabled).toBe(true); + expect(destroy?.disabled).toBe(true); + expect(recreate?.title).toBe('Ask a workspace admin: Ada Owner'); + // Re-typing a machine is admin work too, so the select is copy instead. + expect(view.container.querySelector('[aria-label="Change my machine type"]')).toBeNull(); + expect(view.container.textContent).toContain('Ask a workspace admin: Ada Owner'); + await view.unmount(); + }); + + it('gives a workspace admin every verb, and confirms a type change keeps the disk', async () => { + const setMachineType = vi.fn().mockResolvedValue({ machine: me.machine }); + const view = await render(dialog({ + client: client({ setMachineType }), + workspace: { ...workspace, myRole: 'admin' }, + })); + await settle(); + + expect(buttons(view.container).every((button) => !button.disabled)).toBe(true); + expect(view.container.querySelector('[aria-label="Change my machine type"]')).not.toBeNull(); + expect(view.container.textContent).not.toContain('Ask a workspace admin'); + await view.unmount(); + }); + + it('tells a viewer they hold no machine', async () => { + const view = await render(dialog({ + workspace: { + ...workspace, + myRole: 'viewer', + members: [ada, { ...me, role: 'viewer', machine: null }], + }, + })); + await settle(); + + expect(view.container.textContent).toContain('A viewer holds no machine'); + expect(buttons(view.container)).toHaveLength(0); + await view.unmount(); + }); +}); + +describe('the rail header', () => { + it('opens my machine from its own button', async () => { + const onOpenMachine = vi.fn(); + const view = await render( + undefined} + onSpawnSession={() => undefined} + onOpenPreview={() => undefined} + onOpenPreviewLink={() => undefined} + onOpenMembers={() => undefined} + onOpenDetails={() => undefined} + onOpenMachine={onOpenMachine} + />, + ); + + const machine = view.container.querySelector( + 'button[aria-label="My machine in design-team"]', + ); + await act(async () => machine?.click()); + expect(onOpenMachine).toHaveBeenCalledWith(workspace.id); + // Members wears the Drive page's share icon, which is a 24-grid glyph; + // the strip's own three-node one is gone. + const members = view.container.querySelector( + 'button[aria-label="Members of design-team"] svg', + ); + expect(members?.getAttribute('viewBox')).toBe('0 0 24 24'); + await view.unmount(); + }); +}); diff --git a/packages/webapp/test/template-screen.test.tsx b/packages/webapp/test/template-screen.test.tsx index 4e33ed7a..71d8dc49 100644 --- a/packages/webapp/test/template-screen.test.tsx +++ b/packages/webapp/test/template-screen.test.tsx @@ -873,6 +873,11 @@ describe('create template screen', () => { const listed = () => [...view.container.querySelectorAll('.tplf-repo')] .map((label) => label.textContent); expect(listed()).toEqual(['acme/app']); + // Refresh reads under the list, on its right edge — not in the filter row. + expect(view.container.querySelector('.tplf-repos-controls .tplf-repos-refresh')).toBeNull(); + expect(view.container.querySelector( + '.tplf-repos-list + .tplf-repos-listfoot .tplf-repos-refresh', + )).not.toBeNull(); installedSecond = true; await act(async () => { diff --git a/packages/webapp/test/workspace-strip.test.tsx b/packages/webapp/test/workspace-strip.test.tsx index 2e5ddf1d..a7c43fa4 100644 --- a/packages/webapp/test/workspace-strip.test.tsx +++ b/packages/webapp/test/workspace-strip.test.tsx @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { WorkspaceStrip, workspaceCode } from "../src/shell/WorkspaceStrip.js"; import type { TenantMe } from "../src/api-adapter.js"; import type { CloudWorkspaceModel } from "../src/workspace-store.js"; -import type { WorkspaceDrawerSegment } from "../src/storage.js"; import { render } from "./dom.js"; import { workspaceModelFixture } from "./workspace-fixtures.js"; @@ -34,12 +33,11 @@ function strip(overrides: Partial[0]> = {}) { workspaces={[workspace()]} viewer={viewer} activeWorkspaceId="workspace-one" - openPanels={new Set()} - pendingRequestCount={0} - surfacesEnabled onSelectWorkspace={() => undefined} + onRenameWorkspace={() => undefined} + onOpenWorkspaceSettings={() => undefined} + onInviteToWorkspace={() => undefined} onCreateWorkspace={() => undefined} - onOpenPanel={() => undefined} onSwitchOrg={() => undefined} onCreateOrg={() => undefined} onOpenDrive={() => undefined} @@ -76,6 +74,12 @@ describe("workspace strip", () => { expect(tiles[1]?.getAttribute("aria-current")).toBeNull(); expect(tiles[1]?.className).toContain("shell-wtile--off"); expect(tiles[2]?.getAttribute("aria-label")).toBe("Create workspace"); + // Each workspace tile wears its own gradient; the create tile keeps the + // dashed outline the stylesheet gives it. + expect(tiles[0]?.style.background).toContain("linear-gradient"); + expect(tiles[1]?.style.background).toContain("linear-gradient"); + expect(tiles[0]?.style.background).not.toBe(tiles[1]?.style.background); + expect(tiles[2]?.style.background).toBe(""); await view.unmount(); }); @@ -93,31 +97,93 @@ describe("workspace strip", () => { await view.unmount(); }); - it("focuses a workspace surface without closing it again", async () => { - const onOpenPanel = vi.fn(); - const view = await render(strip({ - onOpenPanel, - openPanels: new Set(["files"]), - pendingRequestCount: 2, - })); + it("offers Drive alone where the panel toggles used to be", async () => { + const onOpenDrive = vi.fn(); + const view = await render(strip({ onOpenDrive })); const surfaces = [...view.container.querySelectorAll( - '[aria-label="Workspace surfaces"] button', + 'nav[aria-label="Drive"] button', )]; - expect(surfaces.map((button) => button.getAttribute("aria-label"))) - .toEqual(["Files", "teenyapps", "Connections"]); - expect(surfaces[0]?.getAttribute("aria-pressed")).toBe("true"); - expect(surfaces[2]?.textContent).toBe("2"); - await act(async () => surfaces[1]?.click()); - expect(onOpenPanel).toHaveBeenCalledWith("previews"); + expect(surfaces.map((button) => button.getAttribute("aria-label"))).toEqual(["Drive"]); + await act(async () => surfaces[0]?.click()); + expect(onOpenDrive).toHaveBeenCalledOnce(); await view.unmount(); }); - it("disables the surfaces when no workspace is open", async () => { - const view = await render(strip({ surfacesEnabled: false })); - const surfaces = [...view.container.querySelectorAll( - '[aria-label="Workspace surfaces"] button', - )]; - expect(surfaces.every((button) => button.disabled)).toBe(true); + it("opens a context menu on a tile, clamped to the viewport", async () => { + const onOpenWorkspaceSettings = vi.fn(); + const onInviteToWorkspace = vi.fn(); + const onSelectWorkspace = vi.fn(); + const view = await render(strip({ + onOpenWorkspaceSettings, + onInviteToWorkspace, + onSelectWorkspace, + })); + const tile = view.container.querySelector( + 'button[aria-label="design-team"]', + ); + await act(async () => tile?.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 40, + clientY: 90, + }))); + + const menu = view.container.querySelector('[role="menu"][aria-label="Workspace design-team"]'); + expect(menu).not.toBeNull(); + expect(menu?.style.left).toBe("40px"); + expect(menu?.style.top).toBe("90px"); + const items = [...menu!.querySelectorAll('[role="menuitem"]')]; + expect(items.map((item) => item.textContent)).toEqual(["Rename", "Settings", "Invite"]); + + await act(async () => items[2]?.click()); + expect(onInviteToWorkspace).toHaveBeenCalledWith("workspace-one"); + // Right-clicking is not left-clicking: the tile was never selected. + expect(onSelectWorkspace).not.toHaveBeenCalled(); + expect(view.container.querySelector('[role="menu"][aria-label="Workspace design-team"]')).toBeNull(); + await view.unmount(); + }); + + it("offers a non-admin Settings alone, and renames through the caller's PATCH", async () => { + const onRenameWorkspace = vi.fn(); + const view = await render(strip({ + workspaces: [workspace({ myRole: "member" })], + onRenameWorkspace, + })); + const openMenu = async () => { + await act(async () => view.container.querySelector( + 'button[aria-label="design-team"]', + )?.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }))); + const menu = view.container.querySelector('[role="menu"][aria-label="Workspace design-team"]'); + return [...(menu?.querySelectorAll('[role="menuitem"]') ?? [])]; + }; + // A member reads the settings and administers nothing (§3). + expect((await openMenu()).map((item) => item.textContent)).toEqual(["Settings"]); + + await act(async () => view.root.render(strip({ + workspaces: [workspace()], + onRenameWorkspace, + }))); + const rename = (await openMenu()).find((item) => item.textContent === "Rename"); + await act(async () => rename?.click()); + + const field = view.container.querySelector('[aria-label="Workspace name"]'); + expect(field?.value).toBe("design-team"); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") + ?.set?.call(field, "renamed-team"); + field?.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => field?.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + }))); + expect(onRenameWorkspace).toHaveBeenCalledWith("workspace-one", "renamed-team"); + expect(view.container.querySelector('[aria-label="Workspace name"]')).toBeNull(); await view.unmount(); }); @@ -149,30 +215,17 @@ describe("workspace strip", () => { await view.unmount(); }); - it("reaches Drive and settings from the account menu", async () => { - const onOpenDrive = vi.fn(); + it("goes straight to settings from the avatar, with no menu in between", async () => { const onOpenSettings = vi.fn(); - const view = await render(strip({ onOpenDrive, onOpenSettings })); - const menu = () => view.container.querySelector( - '[role="menu"][aria-label="Account"]', + const view = await render(strip({ onOpenSettings })); + const avatar = view.container.querySelector( + 'button[aria-label="Settings"]', ); - expect(menu()?.hidden).toBe(true); - await act(async () => view.container.querySelector( - 'button[aria-label="Account: Person"]', - )?.click()); - expect(menu()?.hidden).toBe(false); - - const items = [...menu()!.querySelectorAll('[role="menuitem"]')]; - expect(items.map(({ textContent }) => textContent)) - .toEqual(["Drive", "Settings", "Ask us on Discord"]); - await act(async () => items[0]?.click()); - expect(onOpenDrive).toHaveBeenCalledOnce(); - - await act(async () => view.container.querySelector( - 'button[aria-label="Account: Person"]', - )?.click()); - await act(async () => menu()!.querySelectorAll('[role="menuitem"]')[1]?.click()); + expect(avatar?.title).toBe("Person"); + expect(avatar?.getAttribute("aria-haspopup")).toBeNull(); + await act(async () => avatar?.click()); expect(onOpenSettings).toHaveBeenCalledOnce(); + expect(view.container.querySelector('[role="menu"][aria-label="Account"]')).toBeNull(); await view.unmount(); }); }); diff --git a/packages/webapp/test/workspace-tile.test.ts b/packages/webapp/test/workspace-tile.test.ts new file mode 100644 index 00000000..1e76bad3 --- /dev/null +++ b/packages/webapp/test/workspace-tile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { workspaceTileStyle } from "../src/shell/workspace-tile.js"; + +/** WCAG 2.2 relative luminance, written out here rather than imported, so the + * contrast claim is checked against the published formula and not against the + * helper's own arithmetic. */ +function luminance(red: number, green: number, blue: number): number { + const channel = (value: number) => { + const unit = value / 255; + return unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); +} + +function contrast(one: number, other: number): number { + return (Math.max(one, other) + 0.05) / (Math.min(one, other) + 0.05); +} + +function colors(value: string): number[][] { + return [...value.matchAll(/rgb\((\d+) (\d+) (\d+)\)/gu)] + .map((match) => [Number(match[1]), Number(match[2]), Number(match[3])]); +} + +function inkOverTile(workspaceId: string): number { + const style = workspaceTileStyle(workspaceId); + const stops = colors(style.background); + expect(stops).toHaveLength(2); + const tile = stops + .map(([red, green, blue]) => luminance(red!, green!, blue!)) + .reduce((total, value) => total + value, 0) / stops.length; + const ink = colors(style.color); + expect(ink).toHaveLength(1); + const [red, green, blue] = ink[0]!; + return contrast(luminance(red!, green!, blue!), tile); +} + +const ids = Array.from({ length: 600 }, (_, index) => `workspace-${String(index)}`); + +describe("workspaceTileStyle", () => { + it("paints the same tile for the same id, every time", () => { + expect(workspaceTileStyle("workspace-one")) + .toEqual(workspaceTileStyle("workspace-one")); + expect(workspaceTileStyle("workspace-one").background) + .toBe(workspaceTileStyle("workspace-one").background); + }); + + it("is a two-stop gradient, and the stops are two different hues", () => { + const style = workspaceTileStyle("design-team"); + expect(style.background).toMatch( + /^linear-gradient\(135deg, rgb\(\d+ \d+ \d+\) 0%, rgb\(\d+ \d+ \d+\) 100%\)$/u, + ); + const [start, end] = colors(style.background); + expect(start).not.toEqual(end); + }); + + it("spreads a stripful of workspaces around the wheel", () => { + // 360 hues, so ids collide eventually; a strip holds tens, not hundreds. + const strip = ids.slice(0, 60); + const backgrounds = new Set(strip.map((id) => workspaceTileStyle(id).background)); + expect(backgrounds.size).toBeGreaterThanOrEqual(50); + }); + + it("picks an ink that clears WCAG AA over the gradient it painted", () => { + const ratios = ids.map((id) => inkOverTile(id)); + expect(Math.min(...ratios)).toBeGreaterThanOrEqual(4.5); + }); + + it("uses both inks, so the luminance choice is a real choice", () => { + const inks = new Set(ids.map((id) => workspaceTileStyle(id).color)); + expect(inks.size).toBe(2); + }); +});