diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index c6a82575d..96fb2ebe3 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/__tests__/unit/services/machineService.test.js b/backend/__tests__/unit/services/machineService.test.js index 3696f1646..ed3a023b5 100644 --- a/backend/__tests__/unit/services/machineService.test.js +++ b/backend/__tests__/unit/services/machineService.test.js @@ -141,6 +141,15 @@ describe('ADR-026 machine lifecycle service', () => { ); }); + it('lists an owner’s computers with the createdAt the database stored, so a picker can date a shared name', async () => { + const ownerUserId = new mongoose.Types.ObjectId(); + const saved = await Machine.create({ ownerUserId, machineId: 'mach-round-trip', name: 'Sams-MacBook' }); + + const { machines } = await machineService.listMachinesForOwner(ownerUserId); + + expect(machines).toEqual([expect.objectContaining({ machineId: 'mach-round-trip', createdAt: saved.createdAt })]); + }); + it('returns only the credential-bound machine for daemon status', async () => { const ownerUserId = new mongoose.Types.ObjectId(); const first = await machineService.registerMachine({ ownerUserId, name: 'First Mac' }); diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 2df9e927f..cfd0337be 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 bafc45211..eff659ad5 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 d33af655a..746af5fb9 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 de522bc6a..9bc7f1d03 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 c1f6e88dc..f4e3069e0 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -389,4 +389,47 @@ 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[0]).toMatch(/^Sams-MacBook · added Oct 24, .* \(online\)$/); + expect(labels[1]).toMatch(/^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 d52879c31..da2520bc8 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -73,6 +73,22 @@ 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[0]).toMatch(/^Sams-MacBook · added Oct 24, /); + expect(labels[1]).toMatch(/^Sams-MacBook · added Sep 6, /); + expect(labels[2]).toBe('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/__tests__/machineLabel.test.ts b/frontend/src/v2/__tests__/machineLabel.test.ts new file mode 100644 index 000000000..4d537bedb --- /dev/null +++ b/frontend/src/v2/__tests__/machineLabel.test.ts @@ -0,0 +1,50 @@ +import { machineDisplayName, defaultMachine } from '../utils/machineLabel'; + +// The label is a function of the list it sits in: the same computer reads +// differently when a same-named sibling exists. t echoes the key and date so the +// assertions read the composed string. +const t = (key: string, options?: Record) => `${key.split('.').pop()} ${options?.date ?? ''}`; + +const older = { machineId: 'mach-old-1111', name: 'Sams-MacBook', createdAt: '2026-09-06T12:00:00.000Z' }; +const newer = { machineId: 'mach-new-2222', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:00.000Z' }; + +test('a unique computer name is returned unchanged', () => { + expect(machineDisplayName(older, [older, { machineId: 'x', name: 'Studio' }], t, 'en')).toBe('Sams-MacBook'); +}); + +test('a shared name gets the date it was added, and the two differ', () => { + const all = [newer, older]; + const a = machineDisplayName(newer, all, t, 'en'); + const b = machineDisplayName(older, all, t, 'en'); + expect(a).toMatch(/^Sams-MacBook · addedOn Oct 24, /); + expect(b).toMatch(/^Sams-MacBook · addedOn Sep 6, /); + expect(a).not.toBe(b); +}); + +test('zh-CN reads the added date in Chinese, not English', () => { + const label = machineDisplayName(newer, [newer, older], (key, options) => `添加于 ${options?.date ?? key}`, 'zh-CN'); + // Chinese month and day, no English month. The 12:00Z fixture keeps the date + // the same from UTC-11 to UTC+11. + expect(label).toMatch(/^Sams-MacBook · 添加于 10月24日 /); + expect(label).not.toMatch(/Oct|AM|PM/); +}); + +test('two computers added in the same minute still get distinct labels', () => { + const twinA = { machineId: 'mach-aaaa-0001', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:00.000Z' }; + const twinB = { machineId: 'mach-bbbb-0002', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:30.000Z' }; + const all = [twinA, twinB]; + expect(machineDisplayName(twinA, all, t, 'en')).toMatch(/ · 0001$/); + expect(machineDisplayName(twinB, all, t, 'en')).toMatch(/ · 0002$/); +}); + +test('a shared name with no timestamp falls back to the machine ID tail', () => { + const undated = { machineId: 'mach-cccc-0003', name: 'Sams-MacBook', createdAt: null }; + expect(machineDisplayName(undated, [undated, newer], t, 'en')).toBe('Sams-MacBook · 0003'); +}); + +test('the default is the first online computer, else the first listed', () => { + const online = { status: 'online' as const, id: 'a' }; + const offline = { status: 'offline' as const, id: 'b' }; + expect(defaultMachine([offline, online])).toBe(online); + expect(defaultMachine([offline])).toBe(offline); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 9bae537b7..a037dcc3d 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 05e03b2c3..70ffcb33d 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 ? <>