From 000d6b0218004248f9082b447046f41f20f95d70 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 17 Oct 2026 21:26:35 -0700 Subject: [PATCH 1/2] fix(crew): say when setup replaced Crew, and mark a channel's owner as Channel owner Setup in the desktop app read "Installed Crew 1.93.0." after replacing a same-version copy with other bytes, though the daemon reported the replacement (outcome.upgraded_from, previous_kept) and the CLI and the manual both say which copy was replaced. setup.ts now keeps previous_kept and a validated upgraded_from.version, and the install step says "Replaced Crew {v} with this computer's copy of it." or "Updated Crew from {a} to {b}.", as the CLI's setup_summary_lines does. A fresh install and a reuse read as before. Add people to a channel marked the channel's owner "Team owner": the label tested whether a team had resolved, which is always true for a channel. It now follows the dialog's target, so a channel's owner is "Channel owner" as on the Members tab, and a team keeps "Team owner". --- docs/crew/hosting-a-workspace.md | 2 +- .../crew/dialogs/AddPeopleDialog.test.tsx | 38 +++++++++++ .../crew/dialogs/AddPeopleDialog.tsx | 2 +- .../crew/onboarding/HostDialog.test.tsx | 2 + .../crew/onboarding/JoinDialog.test.tsx | 2 + .../crew/onboarding/NotSetUpPane.test.tsx | 2 + .../crew/onboarding/SetupProgress.test.tsx | 64 ++++++++++++++++++- .../crew/onboarding/SetupProgress.tsx | 12 +++- .../src/components/crew/onboarding/copy.ts | 4 ++ .../components/crew/onboarding/setup.test.ts | 33 ++++++++++ .../src/components/crew/onboarding/setup.ts | 7 ++ 11 files changed, 162 insertions(+), 6 deletions(-) diff --git a/docs/crew/hosting-a-workspace.md b/docs/crew/hosting-a-workspace.md index 6da9e64cb..6e5c7c320 100644 --- a/docs/crew/hosting-a-workspace.md +++ b/docs/crew/hosting-a-workspace.md @@ -57,7 +57,7 @@ Step 2 shows the commands that make a private folder, start Crew and print its d 1. Choose **Set up and start Crew**. 2. If the server asks for a password or a code, the Sign in window opens. Type it there. One sign-in covers the whole setup and **Create workspace** after it. -3. Watch the steps: "Checking server", "Signing in", "Installing Crew in your account", "Starting workspace" and "Ready". Beside the install step, Crew says "Installed Crew {version}." or "Crew {version} was already installed." +3. Watch the steps: "Checking server", "Signing in", "Installing Crew in your account", "Starting workspace" and "Ready". Beside the install step, Crew says "Installed Crew {version}." or "Crew {version} was already installed.", or, when it replaced a copy, "Updated Crew from {old version} to {version}." or "Replaced Crew {version} with this computer’s copy of it." 4. When Crew has started, the dialog moves to step 3 by itself. Biorouter runs exactly the commands shown, as you, plus fixed commands that check the server and install Crew. It sends Crew's server program over the SSH connection itself, checks its SHA-256 on the server, and installs it with one rename, keeping any older copy beside it as `biorouter-crew.previous`. It never replaces a newer copy with an older one, and keeps an older copy that still runs, which `biorouter crew setup --replace-existing` updates ([Upgrade Crew](administration.md#upgrade-crew)). A copy of the same version that is not byte for byte the one Biorouter carries is replaced, and Crew says which copy it replaced. **Stop** ends the setup. Closing the dialog does not stop the commands on the server. diff --git a/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.test.tsx b/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.test.tsx index 2d37a950f..e65f54ab5 100644 --- a/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.test.tsx +++ b/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Invitation, Snapshot } from '../crewApi'; import { forgetJoinerNames, rememberJoinerNames } from '../identity'; +import { membersCopy } from '../pane/copy'; import { CrewControllerProvider, useCrew } from '../state/CrewControllerContext'; import { AddPeopleDialog, type AddPeopleDialogProps } from './AddPeopleDialog'; import { addPeopleCopy } from './copy'; @@ -526,6 +527,43 @@ describe('AddPeopleDialog with no one left to add (QA Q2-22)', () => { expect(rows[1].querySelector('[data-crew-online]')).toBeNull(); }); + // A channel's list marks its owner as the Members tab does, never as the team's owner: Bob owns + // #scratch in Alice's team, and everyone in the team is already in it. + it('marks a channel’s owner "Channel owner" in its Already in list, not "Team owner"', async () => { + const scratch = { + ...methods, + id: 'channel-scratch', + name: 'scratch', + created_by: bob.id, + owner_id: bob.id, + members: [alice.id, bob.id, carol.id], + }; + const base = makeSnapshot(); + const alreadyIn = async () => { + const dialog = await screen.findByRole('dialog'); + const section = within(dialog) + .getByRole('heading', { name: addPeopleCopy.alreadyInPlace('#scratch') }) + .closest('section')!; + const rows = within(section).getAllByRole('listitem'); + const row = (name: string) => rows.find((item) => item.textContent?.includes(name))!; + expect(row('Bob Lee')).toHaveTextContent(membersCopy.owner); + expect(row('Alice Chen')).not.toHaveTextContent(membersCopy.owner); + expect(section).not.toHaveTextContent(addPeopleCopy.teamOwner); + }; + const asBob = renderDirect( + { target: 'channel', targetId: 'channel-scratch' }, + { snapshot: { ...base, actor: bob, channels: [...base.channels, scratch] } } + ); + await alreadyIn(); + asBob.unmount(); + // Carol may not add people to it: the list sits under the note saying who may, marked the same. + renderWithCrew( + , + { snapshot: { ...base, actor: carol, channels: [...base.channels, scratch] } } + ); + await alreadyIn(); + }); + it('opens "Members of {team}…" on the member list for the owner, with Add people one step away (QA Q4-35)', async () => { // Alice is the host and Analysis Lab's owner; Dan is not in the team yet. renderDirect({ target: 'team', targetId: 'team-1', view: 'members' }); diff --git a/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.tsx b/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.tsx index 8d3e27811..5baf3a627 100644 --- a/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.tsx +++ b/ui/desktop/src/components/crew/dialogs/AddPeopleDialog.tsx @@ -370,7 +370,7 @@ export function AddPeopleDialog({ target, targetId, view, onClose }: AddPeopleDi message || membersView ? targetMembers(snapshot, dir, pickerTarget, added, workspaceId) : []; // Marked as the Members tab marks them (UXN-6): the owner (`ownerId`, a team's being its // creator), and who is online, from the verified view only. - const ownerLabel = team ? copy.teamOwner : membersCopy.owner; + const ownerLabel = target === 'team' ? copy.teamOwner : membersCopy.owner; const online = crew.snapshot ? onlineSet(crew.snapshot) : null; // Named as the host knows them (QA Q4-42): "Jack Moreno (@crew_jack)", as Let in named them. const inviteesLine = diff --git a/ui/desktop/src/components/crew/onboarding/HostDialog.test.tsx b/ui/desktop/src/components/crew/onboarding/HostDialog.test.tsx index 7a77ada25..53d26c7d1 100644 --- a/ui/desktop/src/components/crew/onboarding/HostDialog.test.tsx +++ b/ui/desktop/src/components/crew/onboarding/HostDialog.test.tsx @@ -1069,6 +1069,8 @@ describe('HostDialog', () => { outcome: { installedVersion: '1.94.0', reused: false, + previousKept: false, + replacedVersion: null, host: { kind: 'found', text: LINE }, hostCommand: SHOWN, connectionId: null, diff --git a/ui/desktop/src/components/crew/onboarding/JoinDialog.test.tsx b/ui/desktop/src/components/crew/onboarding/JoinDialog.test.tsx index 14c7c5621..2d203f67d 100644 --- a/ui/desktop/src/components/crew/onboarding/JoinDialog.test.tsx +++ b/ui/desktop/src/components/crew/onboarding/JoinDialog.test.tsx @@ -67,6 +67,8 @@ function setupJob(overrides: Partial = {}): SetupRun { outcome: { installedVersion: '1.94.0', reused: true, + previousKept: false, + replacedVersion: null, host: null, hostCommand: null, connectionId: 'conn-new', diff --git a/ui/desktop/src/components/crew/onboarding/NotSetUpPane.test.tsx b/ui/desktop/src/components/crew/onboarding/NotSetUpPane.test.tsx index 6261792ee..51ab4fee4 100644 --- a/ui/desktop/src/components/crew/onboarding/NotSetUpPane.test.tsx +++ b/ui/desktop/src/components/crew/onboarding/NotSetUpPane.test.tsx @@ -137,6 +137,8 @@ describe('NotSetUpPane', () => { outcome: { installedVersion: '1.94.0', reused: false, + previousKept: false, + replacedVersion: null, host: null, hostCommand: null, connectionId: 'conn-1', diff --git a/ui/desktop/src/components/crew/onboarding/SetupProgress.test.tsx b/ui/desktop/src/components/crew/onboarding/SetupProgress.test.tsx index 8d24c3df1..2c25ca124 100644 --- a/ui/desktop/src/components/crew/onboarding/SetupProgress.test.tsx +++ b/ui/desktop/src/components/crew/onboarding/SetupProgress.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CrewHttpError } from '../crewApi'; import { setupCopy } from './copy'; -import type { SetupInput, SetupRun } from './setup'; +import { setupRunFrom, type SetupInput, type SetupRun } from './setup'; import { HOST_STORAGE_UNSAFE_CODE, SetupProgress, useSetupJob } from './SetupProgress'; const mocks = vi.hoisted(() => ({ @@ -146,3 +146,65 @@ describe('a start that failed on the server', () => { expect(screen.getByLabelText(setupCopy.outputLabel)).toHaveTextContent(said); }); }); + +describe('an install that replaced a program', () => { + /** A finished job as the daemon answers it, with `outcome` laid over a fresh install's. */ + const finished = (outcome: Record) => + setupRunFrom({ + job_id: 'job-1', + intent: 'host', + state: 'finished', + stage: 'done', + stages: [ + { name: 'probe', state: 'done', detail: null }, + { + name: 'install', + state: 'done', + detail: 'Replaced Crew 1.93.0 with this app’s build of it.', + }, + ], + outcome: { installed_version: '1.93.0', reused: false, ...outcome }, + }); + const sameVersion = { + previous_kept: true, + upgraded_from: { + version: '1.93.0', + sha256: 'fe'.repeat(32), + installed_version: '1.93.0', + installed_sha256: 'f4'.repeat(32), + }, + }; + /** What the "Installing Crew in your account" step says once the job finished. */ + const installStep = async (outcome: Record) => { + mocks.startSetup.mockResolvedValueOnce(finished(outcome)); + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Start' })); + const step = (await screen.findByText(setupCopy.stages.installing)).closest('li')!; + await waitFor(() => expect(step).toHaveAttribute('data-state', 'done')); + const said = step.textContent; + view.unmount(); + return said; + }; + + it('says it replaced a same-version copy, or updated an older one, as the command line does', async () => { + const replaced = await installStep(sameVersion); + expect(replaced).toContain(setupCopy.replaced('1.93.0')); + expect(replaced).not.toContain(setupCopy.installed('1.93.0')); + expect( + await installStep({ + ...sameVersion, + upgraded_from: { ...sameVersion.upgraded_from, version: '1.92.1' }, + }) + ).toContain('Updated Crew from 1.92.1 to 1.93.0.'); + expect(await installStep({ previous_kept: true, upgraded_from: null })).toContain( + setupCopy.replacedProgram('1.93.0') + ); + }); + + it('still says installed for a fresh install, and already installed for a reuse', async () => { + expect(await installStep({ previous_kept: false, upgraded_from: null })).toContain( + 'Installed Crew 1.93.0.' + ); + expect(await installStep({ reused: true })).toContain('Crew 1.93.0 was already installed.'); + }); +}); diff --git a/ui/desktop/src/components/crew/onboarding/SetupProgress.tsx b/ui/desktop/src/components/crew/onboarding/SetupProgress.tsx index 15d068b99..5910b926f 100644 --- a/ui/desktop/src/components/crew/onboarding/SetupProgress.tsx +++ b/ui/desktop/src/components/crew/onboarding/SetupProgress.tsx @@ -336,9 +336,15 @@ export function SetupProgress({ const installedDetail = outcome?.installedVersion && outcome.reused ? setupCopy.reused(outcome.installedVersion) - : outcome?.installedVersion - ? setupCopy.installed(outcome.installedVersion) - : null; + : outcome?.installedVersion && outcome.replacedVersion + ? outcome.replacedVersion === outcome.installedVersion + ? setupCopy.replaced(outcome.installedVersion) + : setupCopy.updated(outcome.replacedVersion, outcome.installedVersion) + : outcome?.installedVersion && outcome.previousKept + ? setupCopy.replacedProgram(outcome.installedVersion) + : outcome?.installedVersion + ? setupCopy.installed(outcome.installedVersion) + : null; const jobId = job.run?.jobId ?? null; return (
diff --git a/ui/desktop/src/components/crew/onboarding/copy.ts b/ui/desktop/src/components/crew/onboarding/copy.ts index 522378059..effed34c6 100644 --- a/ui/desktop/src/components/crew/onboarding/copy.ts +++ b/ui/desktop/src/components/crew/onboarding/copy.ts @@ -647,6 +647,10 @@ export const setupCopy = { /** Beside "Installing Crew in your account" once done. */ reused: (version: string) => `Crew ${version} was already installed.`, installed: (version: string) => `Installed Crew ${version}.`, + /** An install that replaced a program (`setup_summary_lines` in the CLI says the same). */ + updated: (from: string, to: string) => `Updated Crew from ${from} to ${to}.`, + replaced: (version: string) => `Replaced Crew ${version} with this computer’s copy of it.`, + replacedProgram: (version: string) => `Replaced the Crew program with Crew ${version}.`, stop: 'Stop', /** A Sign in window the person closed before signing in ends the job. */ signInEnded: 'Sign-in didn’t finish, so setup stopped. Try again when you’re ready to sign in.', diff --git a/ui/desktop/src/components/crew/onboarding/setup.test.ts b/ui/desktop/src/components/crew/onboarding/setup.test.ts index 422a31988..5d256f774 100644 --- a/ui/desktop/src/components/crew/onboarding/setup.test.ts +++ b/ui/desktop/src/components/crew/onboarding/setup.test.ts @@ -234,6 +234,8 @@ describe('setupRunFrom', () => { expect(run.outcome).toEqual({ installedVersion: '1.94.0', reused: true, + previousKept: false, + replacedVersion: null, host: { kind: 'found', text: 'brcrew1:abc' }, hostCommand: 'umask 077', connectionId: null, @@ -245,6 +247,37 @@ describe('setupRunFrom', () => { expect(plainStage(run)).toBe('ready'); }); + it('reads which program an install replaced, and only a version that can be shown', () => { + const replacing = (upgraded_from: unknown) => + setupRunFrom({ + ...RUNNING, + state: 'finished', + stage: 'done', + outcome: { + installed_version: '1.93.0', + reused: false, + previous_kept: true, + upgraded_from, + }, + }).outcome; + const sameVersion = { + version: '1.93.0', + sha256: 'fe'.repeat(32), + installed_version: '1.93.0', + installed_sha256: 'f4'.repeat(32), + }; + expect(replacing(sameVersion)).toMatchObject({ previousKept: true, replacedVersion: '1.93.0' }); + expect(replacing({ ...sameVersion, version: '1.92.1' })?.replacedVersion).toBe('1.92.1'); + for (const bad of [ + null, + '1.92.1', + { ...sameVersion, version: null }, + { version: 'not-a-sha' }, + ]) { + expect(replacing(bad)).toMatchObject({ previousKept: true, replacedVersion: null }); + } + }); + it('reads a failed job’s typed code, sentence and output', () => { const run = setupRunFrom({ ...RUNNING, diff --git a/ui/desktop/src/components/crew/onboarding/setup.ts b/ui/desktop/src/components/crew/onboarding/setup.ts index e41a7dcdf..7aea4449f 100644 --- a/ui/desktop/src/components/crew/onboarding/setup.ts +++ b/ui/desktop/src/components/crew/onboarding/setup.ts @@ -84,6 +84,10 @@ export interface SetupOutcome { installedVersion: string | null; /** The install already there was kept: nothing was sent. */ reused: boolean; + /** The program this job replaced stays as `~/.local/bin/biorouter-crew.previous`. */ + previousKept: boolean; + /** The version of the program this job replaced (`upgraded_from.version`), when it named one. */ + replacedVersion: string | null; /** A host job: what the start commands printed, read as `/crew/host/start` reads it. */ host: HostStartResult | null; /** A host job: the exact start command text it ran, for the Host dialog's tripwire. */ @@ -174,9 +178,12 @@ function stagesFrom(wire: unknown): SetupStage[] { function outcomeFrom(wire: unknown): SetupOutcome | null { if (!isRecord(wire)) return null; + const replaced = isRecord(wire.upgraded_from) ? text(wire.upgraded_from.version) : null; return { installedVersion: filled(wire.installed_version), reused: wire.reused === true, + previousKept: wire.previous_kept === true, + replacedVersion: replaced && VERSION.test(replaced) ? replaced : null, host: hostStartResultFrom(wire.host), hostCommand: text(wire.host_command), connectionId: filled(wire.connection_id), From 4ca9458bbab57d4a82f2456bd2e2c923b12a1088 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 17 Oct 2026 23:10:21 -0700 Subject: [PATCH 2/2] docs(crew): name the third sentence setup can show when it replaced a copy --- docs/crew/hosting-a-workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/crew/hosting-a-workspace.md b/docs/crew/hosting-a-workspace.md index 6e5c7c320..6e792b49e 100644 --- a/docs/crew/hosting-a-workspace.md +++ b/docs/crew/hosting-a-workspace.md @@ -57,7 +57,7 @@ Step 2 shows the commands that make a private folder, start Crew and print its d 1. Choose **Set up and start Crew**. 2. If the server asks for a password or a code, the Sign in window opens. Type it there. One sign-in covers the whole setup and **Create workspace** after it. -3. Watch the steps: "Checking server", "Signing in", "Installing Crew in your account", "Starting workspace" and "Ready". Beside the install step, Crew says "Installed Crew {version}." or "Crew {version} was already installed.", or, when it replaced a copy, "Updated Crew from {old version} to {version}." or "Replaced Crew {version} with this computer’s copy of it." +3. Watch the steps: "Checking server", "Signing in", "Installing Crew in your account", "Starting workspace" and "Ready". Beside the install step, Crew says "Installed Crew {version}." or "Crew {version} was already installed.", or, when it replaced a copy, "Updated Crew from {old version} to {version}.", "Replaced Crew {version} with this computer’s copy of it." or, when the old copy's version is not known, "Replaced the Crew program with Crew {version}." 4. When Crew has started, the dialog moves to step 3 by itself. Biorouter runs exactly the commands shown, as you, plus fixed commands that check the server and install Crew. It sends Crew's server program over the SSH connection itself, checks its SHA-256 on the server, and installs it with one rename, keeping any older copy beside it as `biorouter-crew.previous`. It never replaces a newer copy with an older one, and keeps an older copy that still runs, which `biorouter crew setup --replace-existing` updates ([Upgrade Crew](administration.md#upgrade-crew)). A copy of the same version that is not byte for byte the one Biorouter carries is replaced, and Crew says which copy it replaced. **Stop** ends the setup. Closing the dialog does not stop the commands on the server.