Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ happens while nobody is at the keyboard.
- Browser-bridge token setting
- Web runs trust the project for Claude Code automatically β€” no manual trust step
- A cloud run's row follows the session's real branch and PR, with its armed draft PR opened when the session opens none
- Another machine's runs on the shared data branch are told apart: their rows carry a glyph naming the machine that started them (the Overview's working-now card spells it out), and a run is listed once even when two checkouts share its archive

## Notifications

Expand Down
4 changes: 2 additions & 2 deletions packages/framework/dashboard/components/AgentHistory.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The dashboard's left column, present on every page: the brand, the New agent lau
- **New agent adapts to what exists** - inside a project it starts another agent there; with exactly one project registered it starts there; with several it asks which; with none it offers to add a project first, since there is nowhere to run an agent.
- **The recent list follows the scope** - a selected project shows its own agents; the Overview pools every project's agents newest-first, each row naming its project and jumping into it.
- **A started agent appears immediately** - a placeholder row says "starting…" from the moment Start is pressed until the real agent shows up, and gives up after a while if none ever does.
- **Each row says what its agent is doing** - working, waiting on the user, publishing, or its final status β€” and for a cloud session, what the cloud side is doing; plus when it started, which driver ran it, and whether it runs on another device or as a cloud session.
- **Each row says what its agent is doing** - working, waiting on the user, publishing, or its final status β€” and for a cloud session, what the cloud side is doing; plus when it started, which driver ran it, whether it runs on another device or as a cloud session, and which machine started it when that was not this one.
- **Exactly one destination is highlighted** - New, Overview, Tickets, a project, or an agent row.
- **Projects carry their health** - each project shows whether it is activated, and turns red with the reason when the daemon has recorded an error for it.

Expand Down Expand Up @@ -52,7 +52,7 @@ Scanning the list, the user needs to tell at a glance which agents need them, wh

Each row shows a status word with a matching dot: a working agent pulses and reads its running status; an agent parked on the user reads "waiting" with a still dot, because it used to pulse identically whether it was mid-edit or had been idle for an hour; an agent that ended clean but has not yet reported its handoff reads "publishing…" with a pulsing dot, matching the wording on its own page; an agent that handed its task to a cloud session reads, once its local half is done, what the cloud side is doing per the cloud state rule (`cloud-run-state`): "waiting" with a still dot when the browser bridge holds a question its session is parked on, "in cloud" while the session is assumed to still be working, "merged" once the framework merged its pull request, and "done" once it has a pull request or the session can no longer be working β€” never "in cloud" forever. Otherwise the row shows the agent's final status.

Beside that: how long ago it started (preceded by its project name on the Overview), and, at the end of the line, a device glyph naming the device when the agent runs on another machine, a cloud glyph when it is a cloud session, and the logo of the driver that ran it. Underneath sits the agent's task; a task too long for the rail's fixed width is faded at its end and shows in full on hover, while one that fits is shown plainly with no hover at all.
Beside that: how long ago it started (preceded by its project name on the Overview), and, at the end of the line, a glyph naming the machine when another machine's daemon started the agent β€” the data branch is shared, so other machines' agents are listed here, and one that looked like this daemon's own was a mystery; a glyph rather than a word, since the rail's fixed width would cut a machine name short β€” a device glyph naming the device when the agent runs on another machine, a cloud glyph when it is a cloud session, and the logo of the driver that ran it. Underneath sits the agent's task; a task too long for the rail's fixed width is faded at its end and shows in full on hover, while one that fits is shown plainly with no hover at all.

### Destinations and what is highlighted

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ What the tests cover:

- **What a row's status says** - a working agent reads "running" and animates; one parked on the user reads "waiting" with the animation stopped; an agent that ended clean with a handoff still to come reads "publishing…" and animates, and reads plain "done" once the handoff has reported or when none was armed; a finished agent is never relabelled "waiting".
- **The starting placeholder** - it carries the highlight while the user follows an agent whose row has not arrived yet; it retires as soon as any agent lands that was not in the list when Start was pressed, even one that failed too fast to ever be seen running; and an agent that was already in the list does not count as that handover.
- **Where the agent runs** - an agent on another device shows a glyph naming that device, while a local one shows none; an agent handed to a cloud session reads "in cloud" while its session may still be working, "waiting" when the bridge holds a question for it, "merged" once the framework merged its pull request, and "done" once it has a pull request or is too old for its session to be alive instead of "done" (because the cloud side is still working), shows a cloud glyph, and still names its driver β€” but one stopped early reads plainly "stopped", since nothing is working anywhere.
- **Where the agent runs** - an agent on another device shows a glyph naming that device, while a local one shows none; an agent another machine's daemon started shows a glyph naming that machine, while this daemon's own shows none; an agent handed to a cloud session reads "in cloud" while its session may still be working, "waiting" when the bridge holds a question for it, "merged" once the framework merged its pull request, and "done" once it has a pull request or is too old for its session to be alive instead of "done" (because the cloud side is still working), shows a cloud glyph, and still names its driver β€” but one stopped early reads plainly "stopped", since nothing is working anywhere.
- **Scope of the list** - with no project selected the rail still shows New agent and says "No agents yet."; the Overview pools every project's agents, naming each row's project, and selecting one jumps into that project's agent.
- **New agent** - with one project it starts there; inside a project it starts another agent there; with several projects and none selected it opens a picker instead of starting immediately.
- **Tickets** - offered both inside a project and on the Overview, opens the ticket view, carries the active marker while it is the current view (with Overview not also claiming it), and is not offered at all when there is nowhere to route it.
Expand Down
10 changes: 10 additions & 0 deletions packages/framework/dashboard/components/AgentHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ describe('AgentHistory rows', () => {
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
})

test('a run another machine started says whose it is; this daemon\'s own does not (#1067, #1648)', () => {
const rows = [
{ projectId: 'p', projectName: 'gemstack', agent: agent({ id: 'theirs', host: 'rom-thinkpad-x280', otherHost: true }) },
{ projectId: 'p', projectName: 'gemstack', agent: agent({ id: 'mine', host: 'this-mac' }) },
]
renderRail(<AgentHistory projectId={null} agents={[]} recentAgents={rows} selectedAgentId={null} onSelect={() => {}} />)
expect(screen.getByLabelText('Started on rom-thinkpad-x280')).toBeTruthy()
expect(screen.queryByLabelText('Started on this-mac')).toBeNull()
})

test('a local run has no device glyph (#1067)', () => {
renderRail(<AgentHistory projectId="p1" agents={[agent()]} selectedAgentId={null} onSelect={() => {}} />)
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
Expand Down
20 changes: 18 additions & 2 deletions packages/framework/dashboard/components/AgentHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Plus, ChevronDown, Cloud, MonitorSmartphone, Settings, LayoutDashboard, FolderGit2, Ticket } from 'lucide-react'
import { Plus, ChevronDown, Cloud, Laptop, MonitorSmartphone, Settings, LayoutDashboard, FolderGit2, Ticket } from 'lucide-react'
import type { AgentMeta, AgentStatus, RecentAgent, ProjectSummary } from '../../src/index.js'
import { DRIVER_LABELS, driverFromImpl, cloudRunState, type CloudRunState } from '../../src/client.js'
import { Button, buttonVariants } from './ui/button.js'
Expand Down Expand Up @@ -257,6 +257,7 @@ export function AgentHistory({
waiting={row.agent.settledAt !== undefined}
remote={row.agent.target === 'remote'}
cloud={row.agent.target === 'web'}
{...(row.agent.otherHost && row.agent.host ? { startedOn: row.agent.host } : {})}
cloudState={cloudRunState(row.agent, Date.now())}
{...(row.agent.remoteLabel ? { remoteLabel: row.agent.remoteLabel } : {})}
onClick={row.onClick}
Expand Down Expand Up @@ -531,6 +532,7 @@ function AgentHistoryRow({
cloud = false,
cloudState,
remoteLabel,
startedOn,
}: {
status: AgentStatus
intent: string | undefined
Expand All @@ -553,6 +555,8 @@ function AgentHistoryRow({
cloudState?: CloudRunState | undefined
/** The device's label, for the glyph's tooltip. */
remoteLabel?: string | undefined
/** The machine whose daemon started the run, when that is another machine (#1648): a glyph names it. */
startedOn?: string | undefined
}) {
// Only a live agent can be waiting on you; a finished one is just finished β€” except a web run
// whose cloud session the bridge reports as parked (#1668).
Expand Down Expand Up @@ -608,8 +612,20 @@ function AgentHistoryRow({
a cloud glyph for a Claude Code cloud session (#1263), then the driver logo. The logo
is the only thing naming the driver on this row, so it carries a title rather than
being decorative. */}
{(remote || cloud || picked) && (
{(remote || cloud || picked || startedOn) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{/* Another machine's daemon started this run (#1648): the shared data branch lists every
machine's runs here, and one that looked exactly like this daemon's own was a mystery
solved only by reading the archive. A glyph, not a word in the meta line: the rail's
fixed width truncated a hostname to "from…". */}
{startedOn && (
<Tooltip>
<TooltipTrigger render={<span className="flex items-center" />}>
<Laptop className="h-3 w-3 text-muted-foreground" aria-label={`Started on ${startedOn}`} />
</TooltipTrigger>
<TooltipContent>Started on {startedOn}, by that machine's daemon.</TooltipContent>
</Tooltip>
)}
{remote && (
<Tooltip>
<TooltipTrigger render={<span className="flex items-center" />}>
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/dashboard/components/Agents.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The user recognises an agent in this card by the same wording they see everywher

#### Business logic

A row shows the agent's one-liner, its project's name, and how long ago it was last active, with the exact date and time on hover. The one-liner is the agent's intent, falling back to the session name it chose for itself, then to its scope, and finally to its project's name β€” a working agent almost always has an intent or a chosen name.
A row shows the agent's one-liner, its project's name, "from" and the machine's name when another machine's daemon started it, and how long ago it was last active, with the exact date and time on hover. The one-liner is the agent's intent, falling back to the session name it chose for itself, then to its scope, and finally to its project's name β€” a working agent almost always has an intent or a chosen name.

## Before modifying/creating SPEC.md files

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What the tests cover: the Overview's Agents card.
- When nothing is working, the card says so instead of disappearing.
- While the list is still being fetched, that is shown as loading rather than as "nothing is working".
- An agent with no intent still gets a readable label β€” the session name it chose β€” instead of a blank line.
- An agent another machine's daemon started names that machine; this daemon's own agents do not.

## Before modifying/creating SPEC.md files

Expand Down
8 changes: 8 additions & 0 deletions packages/framework/dashboard/components/Agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,11 @@ describe('Agents (#1139)', () => {
expect(screen.getByText('oauth-work')).toBeTruthy()
})
})

describe('Agents (#1648)', () => {
test('a run another machine started says whose it is; this daemon\'s own does not', () => {
render(<Agents working={[active('theirs', { host: 'rom-thinkpad-x280' }), active('mine')]} loading={false} onSelectAgent={vi.fn()} />)
expect(screen.getByText('from rom-thinkpad-x280')).toBeTruthy()
expect(screen.getAllByText(/^from /)).toHaveLength(1)
})
})
6 changes: 6 additions & 0 deletions packages/framework/dashboard/components/Agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function Agents({
key={`${a.projectId}:${a.agentId}`}
label={activeLabel(a)}
cloud={a.cloud}
host={a.host}
at={a.updatedAt}
projectName={a.projectName}
onOpen={() => onSelectAgent(a.projectId, a.agentId)}
Expand All @@ -62,6 +63,7 @@ export function Agents({
function AgentRow({
label,
cloud,
host,
at,
projectName,
onOpen,
Expand All @@ -70,6 +72,8 @@ function AgentRow({
label: string
/** A web run's cloud side at work (#1668): say where it is, since no local process backs the row. */
cloud?: 'in-cloud' | 'waiting' | undefined
/** The machine whose daemon started it (#1648), when that is not this one. */
host?: string | undefined
/** ISO: the session's last activity. */
at: string | undefined
projectName: string
Expand All @@ -89,6 +93,8 @@ function AgentRow({
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
{cloud && <span className="shrink-0 text-[10px] uppercase text-muted-foreground">{cloud === 'waiting' ? 'waiting' : 'in cloud'}</span>}
<span className="shrink-0 text-xs text-muted-foreground">{projectName}</span>
{host && <span className="shrink-0 text-xs text-muted-foreground">from {host}</span>}

{at && (
<Tooltip>
<TooltipTrigger
Expand Down
3 changes: 2 additions & 1 deletion packages/framework/src/dashboard-rpc/reads.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The dashboard is a projection of what the daemon and its agents write to disk. E
- **A read never fails at the user** - an unknown project, a repo that is not a git repo, or a read that goes wrong all answer with an empty result, so a panel renders as empty instead of erroring.
- **An agent-scoped read is answered by whoever owns the agent** - a locally running agent is read off its own worktree; an agent running on a device is read over the relay from that device.
- **The agent list merges three sources into one row per agent** - the archived agents, every live agent, and the agents this daemon is relaying from a device; where the same agent appears twice, the live copy wins.
- **A record is annotated with what only the daemon knows** - on the way to the dashboard, an agent's record is marked as waiting when the browser bridge holds its session's question, and as from another host when a different machine's daemon started it; the record on disk carries neither.
- **What the agent changed is read from git, not from the agent** - the changed files, their statuses and their diffs come from the agent's own checkout.
- **A file read cannot be talked into reading something else** - the server decides from its own git status whether a path is a changed file, and refuses paths that escape the checkout.
- **A finished agent is described by its branch, not by a checkout it no longer has** - the handoff reads the agent's branch from the project, so it never reports the user's own branch and uncommitted files as the agent's.
Expand Down Expand Up @@ -46,7 +47,7 @@ The user starts an agent and sees it appear in the sidebar as running straight a

#### Business logic

The list is the project's archived agents, plus every agent currently live in its own worktree, plus the agents this daemon is relaying from devices β€” which exist only in the daemon's memory and would otherwise be lost on a reload. There is exactly one row per agent id: a relayed copy beats a local one, and a live copy beats an archived one, because the live copy is the current truth. Nothing is filtered out by status, so an agent whose record was just corrected does not flicker out of the list for a poll. A `web`-target agent whose cloud session the browser bridge reports as parked on a question is marked as waiting on the way out β€” the record on disk cannot know, only the daemon does β€” so its row says "waiting" rather than "in cloud".
The list is the project's archived agents, plus every agent currently live in its own worktree, plus the agents this daemon is relaying from devices β€” which exist only in the daemon's memory and would otherwise be lost on a reload. There is exactly one row per agent id: a relayed copy beats a local one, and a live copy beats an archived one, because the live copy is the current truth. Nothing is filtered out by status, so an agent whose record was just corrected does not flicker out of the list for a poll. A `web`-target agent whose cloud session the browser bridge reports as parked on a question is marked as waiting on the way out β€” the record on disk cannot know, only the daemon does β€” so its row says "waiting" rather than "in cloud". Likewise an agent whose recorded host is not this machine is marked as from another host: the data branch is shared precisely so that other machines' agents appear here, and a row that looked exactly like one of this daemon's own left the user reading the archive by hand to learn whose it was. A record with no host at all is left alone. The same marks are applied to the Overview's pooled recent agents.

### Where an agent is working

Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/dashboard-rpc/reads.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
What the tests cover: a read for a project that is not registered has no checkout to answer from and says so with that read's empty result β€” an empty file list, an empty per-file status map, no worktree β€” rather than failing. An agent id that could escape the worktrees directory is refused, since the id names a directory.

Also: a `web`-target agent whose session the browser bridge holds a question for is handed to the dashboard marked as waiting; one with no such question, or a local agent, is handed over untouched.
Also: a `web`-target agent whose session the browser bridge holds a question for is handed to the dashboard marked as waiting; one with no such question, or a local agent, is handed over untouched. An agent whose recorded host is another machine is handed over marked as from another host; one this machine started, or one with no recorded host, is not.

## Before modifying/creating SPEC.md files

Expand Down
Loading
Loading