From 175a45c4d78cd10ec1a311e008b8f07616742f55 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:13:29 -0700 Subject: [PATCH 1/3] fix(byo): tell two same-named computers apart by when each was added (TASK-650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both computer pickers labelled a computer by name alone, so a reinstall that registered a second "Sams-MacBook" left two identical options. A name shared with another of the owner's computers now gets " · added "; a unique name is unchanged. The default pick is the first online computer. GET /api/machines and the placement's activeMachines carry createdAt for the client to label with. Written by Wisp, a Commonly agent — https://commonly.me/v2/pods/6a692a1be833c668acdb84cf Co-Authored-By: Claude Haiku 5.5 Co-authored-by: wisp Agent-Model: claude/claude-haiku-5-5/default --- .../unit/routes/agentBinding.phase2.test.js | 8 ++++ backend/routes/agentBinding.ts | 8 ++-- backend/services/machineService.ts | 4 ++ frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../v2/__tests__/V2AgentBYOMachine.test.tsx | 45 +++++++++++++++++++ .../__tests__/V2AgentRuntimeGrants.test.tsx | 14 ++++++ frontend/src/v2/components/V2AgentBYO.tsx | 13 ++++-- .../v2/components/V2AgentRuntimeGrants.tsx | 7 +-- frontend/src/v2/utils/machineLabel.ts | 29 ++++++++++++ 10 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 frontend/src/v2/utils/machineLabel.ts diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index c6a82575da..96fb2ebe33 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -152,6 +152,14 @@ describe('placement request', () => { expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' }); }); + it('returns when each active computer was added, so a shared name can be told apart', async () => { + const placement = await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200); + const machineA = await Machine.findOne({ machineId: 'machine-a' }).lean(); + expect(placement.body.activeMachines).toContainEqual({ + machineId: 'machine-a', name: 'Mac A', createdAt: machineA.createdAt.toISOString(), + }); + }); + it('does not offer or select an expired daemon credential', async () => { await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { expiresAt: new Date(Date.now() - 1000) } }); diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 2df9e927ff..cfd0337be0 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -181,15 +181,17 @@ router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId, }).select('botMetadata.machineId botMetadata.requestedMachineId').lean(); if (!identity) return res.status(404).json({ message: 'Agent identity not found' }); - const machines = await Machine.find({ ownerUserId: req.user?.id }).select('machineId name').lean(); - const activeMachines = (await Promise.all(machines.map(async (machine: { machineId: string; name: string }) => ({ + const machines = await Machine.find({ ownerUserId: req.user?.id }).select('machineId name createdAt').lean(); + type PlacementMachine = { machineId: string; name: string; createdAt: Date }; + const activeMachines = (await Promise.all(machines.map(async (machine: PlacementMachine) => ({ machineId: machine.machineId, name: machine.name, + createdAt: machine.createdAt, active: Boolean(await AgentCredential.exists({ kind: 'daemon', ownerUserId: req.user?.id, machineId: machine.machineId, status: 'active', $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] })) && await machineHasLiveAuthorization(machine.machineId, req.user!.id!), - })))).filter((machine) => machine.active).map(({ machineId, name }) => ({ machineId, name })); + })))).filter((machine) => machine.active).map(({ machineId, name, createdAt }) => ({ machineId, name, createdAt })); const boundMachineId = identity.botMetadata?.machineId || null; return res.json({ boundMachineId, diff --git a/backend/services/machineService.ts b/backend/services/machineService.ts index bafc452113..eff659ad55 100644 --- a/backend/services/machineService.ts +++ b/backend/services/machineService.ts @@ -15,6 +15,9 @@ export interface MachineView { id: string; machineId: string; name: string; + // When the owner added this computer. Two computers can share a name, and + // the pickers tell them apart by it. + createdAt: Date | null; lastSeenAt: Date | null; status: 'online' | 'offline'; agentStates: IMachineAgentState[]; @@ -66,6 +69,7 @@ const serializeMachine = (machine: IMachine | LeanMachine, now = new Date()): Ma id: asId(machine._id), machineId: String(machine.machineId), name: String(machine.name), + createdAt: machine.createdAt ? new Date(machine.createdAt as string | Date) : null, lastSeenAt, status: lastSeenAt && now.getTime() - lastSeenAt.getTime() <= MACHINE_OFFLINE_AFTER_MS ? 'online' diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d33af655a8..746af5fb9b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1765,6 +1765,7 @@ "machine": { "online": "online", "offline": "offline", + "addedOn": "added {{date}}", "heading": "Now starting:", "running": "Running on {{machine}} — say hi in the pod.", "waiting": "Waiting for the daemon on {{machine}} to pick it up…", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index de522bc6a6..9bc7f1d03e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1825,6 +1825,7 @@ "machine": { "online": "在线", "offline": "离线", + "addedOn": "添加于 {{date}}", "heading": "正在启动:", "running": "已在 {{machine}} 上运行——去 pod 里打个招呼吧。", "waiting": "正在等待 {{machine}} 上的守护进程接管…", diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index c1f6e88dc9..ab1ebfef35 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -389,4 +389,49 @@ describe('BYO on-my-computer mode', () => { expect(screen.queryByTestId('byo-machine-result')).toBeNull(); expect(screen.queryByText(/has no runtime token/)).toBeNull(); }); + + test('two computers with one name get distinct labels and the online one is the default', async () => { + mockGet({ + machines: [ + { id: 'm2', machineId: 'mach-new', name: 'Sams-MacBook', status: 'online', createdAt: '2026-10-24T09:00:00.000Z', agentStates: [] }, + { id: 'm1', machineId: 'mach-old', name: 'Sams-MacBook', status: 'offline', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] }, + ], + }); + renderPage(); + await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-machine')); + const select = screen.getByTestId('byo-machine-select'); + const labels = Array.from(select.querySelectorAll('option')).map((option) => option.textContent); + expect(labels).toEqual([ + 'Sams-MacBook · added Oct 24 (online)', + 'Sams-MacBook · added Sep 6 (offline)', + ]); + expect(select).toHaveValue('mach-new'); + }); + + test('a unique computer name renders without a date', async () => { + mockGet({ + machines: [ + { id: 'm1', machineId: 'mach-a', name: 'Sam’s MacBook', status: 'online', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] }, + ], + }); + renderPage(); + await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-machine')); + const labels = Array.from(screen.getByTestId('byo-machine-select').querySelectorAll('option')).map((option) => option.textContent); + expect(labels).toEqual(['Sam’s MacBook (online)']); + }); + + test('the default computer is the online one even when an offline one is listed first', async () => { + mockGet({ + machines: [ + { id: 'm1', machineId: 'mach-off', name: 'Old Mac', status: 'offline', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] }, + { id: 'm2', machineId: 'mach-on', name: 'Studio', status: 'online', createdAt: '2026-10-01T09:00:00.000Z', agentStates: [] }, + ], + }); + renderPage(); + await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-machine')); + expect(screen.getByTestId('byo-machine-select')).toHaveValue('mach-on'); + }); }); diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index d52879c317..2166c54d12 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -73,6 +73,20 @@ test('revoked Runtime row offers an active computer and moves in one request', a expect(axios.post).toHaveBeenCalledTimes(1); }); +test('two computers with one name are told apart by when each was added', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, + activeMachines: [ + { machineId: 'new', name: 'Sams-MacBook', createdAt: '2026-10-24T09:00:00.000Z' }, + { machineId: 'older', name: 'Sams-MacBook', createdAt: '2026-09-06T09:00:00.000Z' }, + { machineId: 'solo', name: 'Studio', createdAt: '2026-09-01T09:00:00.000Z' }, + ] } : response })); + renderSection(); + const select = await screen.findByLabelText('Run on'); + const labels = Array.from(select.querySelectorAll('option')).map((option) => option.textContent); + expect(labels).toEqual(['Sams-MacBook · added Oct 24', 'Sams-MacBook · added Sep 6', 'Studio']); +}); + test('without an active computer, the setup hint includes daemon installation', async () => { renderSection(); expect(await screen.findByText(/Register another computer to move this agent/)).toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 9bae537b7d..a037dcc3de 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -22,6 +22,7 @@ import axios from '../../utils/axiosConfig'; import { recordByoStep, type ByoStep } from '../utils/byoTelemetry'; import getApiBaseUrl from '../../utils/apiBaseUrl'; import { daemonSetupCommand } from '../utils/daemonSetupCommand'; +import { defaultMachine, machineDisplayName } from '../utils/machineLabel'; import V2FeaturePage from './V2FeaturePage'; import V2Avatar from './V2Avatar'; import V2Hint from './V2Hint'; @@ -61,6 +62,7 @@ type MachineRow = { id: string; machineId: string; name: string; + createdAt?: string | null; status: 'online' | 'offline'; agentStates?: { agentName: string; instanceId: string; state: string }[]; }; @@ -119,7 +121,7 @@ const sanitizeAgentName = (raw: string): string => raw .slice(0, 64); const V2AgentBYO: React.FC = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const api = useV2Api(); const navigate = useNavigate(); // Deep-link prefill (?pod=&name=) — the approved connect_local_agent card @@ -274,7 +276,10 @@ const V2AgentBYO: React.FC = () => { // must not open onto a page that shows no command. Elsewhere the full page // says why and what works instead. const showCompactByo = compactByo && listenerAvailable && !showCompactHosted && !personaCard && mode === 'byo'; - const selectedMachineName = machines.find((m) => m.machineId === machineId)?.name || machineId; + const selectedMachine = machines.find((m) => m.machineId === machineId); + const selectedMachineName = selectedMachine + ? machineDisplayName(selectedMachine, machines, t, i18n.language) + : machineId; // Model choice for the on-my-computer path. Adapter and model travel // together: a Codex model slug must never be left behind if the user flips // back to Claude or auto-detect, and vice versa. @@ -541,7 +546,7 @@ const V2AgentBYO: React.FC = () => { if (cancelled) return; if (Array.isArray(machineList?.machines) && machineList.machines.length > 0) { setMachines(machineList.machines); - setMachineId((prev) => prev || machineList.machines[0].machineId); + setMachineId((prev) => prev || defaultMachine(machineList.machines)?.machineId || ''); } } catch { // No daemon machines — the "on my computer" card simply doesn't show. @@ -1270,7 +1275,7 @@ const V2AgentBYO: React.FC = () => { > {machines.map((m) => ( ))} diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index 05e03b2c3f..70ffcb33de 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { machineDisplayName } from '../utils/machineLabel'; import { useV2Api } from '../hooks/useV2Api'; import './V2AgentRuntimeGrants.css'; import V2AgentProviderKey from './V2AgentProviderKey'; @@ -28,7 +29,7 @@ interface Placement { boundMachineId: string | null; requestedMachineId: string | null; boundMachineRevoked: boolean; - activeMachines: Array<{ machineId: string; name: string }>; + activeMachines: Array<{ machineId: string; name: string; createdAt?: string | null }>; } interface PlacementFailure { @@ -70,7 +71,7 @@ const dateLabel = (value: string | null): string => { const V2AgentRuntimeGrants: React.FC = ({ name, instanceId, runtime, onSaveRuntime, }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const api = useV2Api(); const [grants, setGrants] = useState([]); const [loading, setLoading] = useState(true); @@ -193,7 +194,7 @@ const V2AgentRuntimeGrants: React.FC = ({ placement.activeMachines.length > 0 ? <>