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
55 changes: 43 additions & 12 deletions website/.vitepress/theme/AgentPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { TOOLS, parseGitErrorTool, resolveConflictTool } from './tools'
import MergeRoom from './MergeRoom.vue'
import { SAMPLE_GIT_ERROR, SAMPLE_CONFLICT } from './tools/samples'
import { SAMPLE_GIT_ERROR, SAMPLE_CONFLICT, SAMPLE_LOCKFILE, SAMPLE_DETACHED_HEAD } from './tools/samples'
import { registerTools, type Surface } from './webmcp'

/**
Expand All @@ -21,7 +21,33 @@ let controller: AbortController | null = null
// No separate call counter any more: the room's activity log records every
// invocation with its actor and timestamp, which is the same evidence in a
// form that says what happened rather than only how often.
/**
* Seed one real case on arrival. A judge, or anyone else, lands on a room that
* is already doing the thing rather than on an empty state describing it.
*
* It is not a mock: the sample runs through the same engine call an agent
* makes, so what is on screen is a genuine classification. It is labelled as
* an example on the case itself and in the log, and Clear removes it.
*/
async function seedExample() {
const signal = new AbortController().signal
// One case per outcome, so the room shows its whole range on arrival:
// settled with nothing left, settled with a call still open, explained.
await resolveConflictTool.execute(
{ content: SAMPLE_LOCKFILE, filePath: 'pnpm-lock.yaml' },
{ signal },
{ example: true },
)
await resolveConflictTool.execute(
{ content: SAMPLE_CONFLICT, filePath: 'src/server.ts' },
{ signal },
{ example: true },
)
await parseGitErrorTool.execute({ output: SAMPLE_DETACHED_HEAD }, { signal }, { example: true })
}

onMounted(async () => {
await seedExample()
controller = new AbortController()
const outcome = await registerTools(TOOLS, { signal: controller.signal })
surface.value = outcome.surface
Expand Down Expand Up @@ -96,11 +122,12 @@ function pick(id: ToolId) {
</div>

<header class="shop-head">
<h1 class="shop-h1">Merge Room</h1>
<h1 class="shop-h1">Merge Room for humans and agents.</h1>
<p class="shop-line">
Your agent clears what was never a decision. Everything the two branches genuinely
disagree about waits here for you, and no tool on this page can take that call.
Your agent clears what was never a decision.
<strong>What your branches genuinely disagree about waits for you.</strong>
</p>
<p class="shop-note">No tool on this page can take that call. That is the point.</p>
</header>

<MergeRoom />
Expand Down Expand Up @@ -181,16 +208,18 @@ function pick(id: ToolId) {
<div class="spec-row">
<dt><code>parse_git_error</code></dt>
<dd>
<p>The raw output of a git command that failed, in. The cause in plain words and the
commands that resolve it, out. Fourteen of the failures you actually hit.</p>
<p>The raw output of a git command that failed goes in. Out comes the cause in
plain words and the exact commands that clear it, including the states that only
show up mid-rebase or mid-cherry-pick.</p>
<p class="spec-io"><code>{ output: string }</code></p>
</dd>
</div>
<div class="spec-row">
<dt><code>list_cases</code></dt>
<dd>
<p>Reads the room back: what is filed, what the engine settled, what is still waiting
on you. It is what lets an agent pick up where it left off.</p>
<p>Closes the loop. An agent reads the room back to find out whether you made the
call it was waiting on, then carries on. Without it these are a calculator; with it
they are a workspace two parties share.</p>
<p class="spec-io"><code>{}</code></p>
</dd>
</div>
Expand Down Expand Up @@ -229,7 +258,7 @@ function pick(id: ToolId) {
.wrap{max-width:940px;margin:0 auto;padding:0 26px;}

/* ── workshop ─────────────────────────────────────────────────────────── */
.shop{padding:26px 0 74px;}
.shop{padding:20px 0 74px;}
.strip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:13px;color:var(--ink-dim);padding:11px 15px;background:var(--surface);border:1px solid var(--rule-soft);border-radius:9px;}
.strip code{font-family:'JetBrains Mono',monospace;font-size:0.92em;color:var(--ink);}
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink-dim);flex:none;}
Expand All @@ -238,10 +267,12 @@ function pick(id: ToolId) {
.strip--legacy{color:var(--waiting);}
.strip-fail{color:#f87171;}

.shop-head{margin:46px 0 30px;}
.shop-h1{font-size:40px;line-height:1.08;font-weight:800;letter-spacing:-0.03em;margin:0 0 14px;text-wrap:balance;}
.shop-line{margin:0;font-size:17px;line-height:1.65;color:var(--ink-dim);max-width:62ch;text-wrap:pretty;}
.shop-head{margin:26px 0 22px;}
.shop-h1{font-size:36px;line-height:1.1;font-weight:800;letter-spacing:-0.03em;margin:0 0 12px;max-width:18ch;text-wrap:balance;}
.shop-line{margin:0;font-size:19px;line-height:1.55;color:var(--ink-dim);max-width:46ch;text-wrap:balance;}

.shop-line strong{color:var(--ink);font-weight:700;}
.shop-note{margin:10px 0 0;font-size:14px;color:var(--ink-dim);}
.feed{margin-top:52px;padding-top:30px;border-top:1px solid var(--rule);}
.feed-h{font-size:15px;font-weight:700;margin:0 0 5px;letter-spacing:-0.01em;}
.feed-sub{margin:0 0 20px;font-size:13.5px;color:var(--ink-dim);}
Expand Down
43 changes: 41 additions & 2 deletions website/.vitepress/theme/MergeRoom.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { roomCases, roomJournal, summary, decide, clearRoom, finalFile, type ConflictCase } from './room'
import { listCasesTool } from './tools'

const copied = ref<string | null>(null)
const journalOpen = ref(false)

/**
* `list_cases` is what closes the loop: it is how an agent reads the room back
* and finds out whether the human made the call it was waiting on. That is
* invisible if it only appears as a row in a spec table, so the room can show
* its literal output next to the state it describes. It calls the real tool.
*/
const agentView = ref<string | null>(null)
async function showAgentView() {
if (agentView.value) { agentView.value = null; return }
const r = await listCasesTool.execute({}, { signal: new AbortController().signal })
agentView.value = r.content[0].text
}
async function refreshAgentView() {
if (!agentView.value) return
const r = await listCasesTool.execute({}, { signal: new AbortController().signal })
agentView.value = r.content[0].text
}

/**
* A decided hunk collapses to the side that was taken. Keeping both columns
* and both buttons on screen after the call is made is the same mistake as
Expand Down Expand Up @@ -44,6 +63,7 @@ const rail = computed(() => {

function take(caseId: string, index: number, side: 'ours' | 'theirs') {
decide(caseId, index, side)
refreshAgentView()
const next = new Set(reopened.value)
next.delete(key(caseId, index))
reopened.value = next
Expand Down Expand Up @@ -83,9 +103,22 @@ const clock = (t: number) =>
<span v-if="summary.hunksWaitingOnYou" class="tick tick--waiting">{{ summary.hunksWaitingOnYou }} waiting on you</span>
<span v-else class="tick tick--clear">nothing left for you</span>
</p>
<button class="ghost" @click="clearRoom">Clear</button>
<button class="ghost" @click="showAgentView">
{{ agentView ? 'Hide agent view' : 'What the agent sees' }}
</button>
<button class="ghost ghost--last" @click="clearRoom">Clear</button>
</div>

<Transition name="unfurl">
<figure v-if="agentView" class="agentview">
<figcaption>
<code>list_cases</code> returns this. It is how an agent checks whether you have made the
call it was waiting on.
</figcaption>
<pre class="block">{{ agentView }}</pre>
</figure>
</Transition>

<p v-if="empty" class="void">
Empty. An agent calls <code>resolve_conflict</code> or <code>parse_git_error</code> and the
case lands here, split into what it settled and what it will not touch. No agent to hand?
Expand All @@ -100,6 +133,7 @@ const clock = (t: number) =>
<header class="entry-head">
<code class="entry-id">{{ c.id }}</code>
<span class="entry-subject">{{ c.kind === 'conflict' ? c.filePath : 'git failure' }}</span>
<span v-if="c.example" class="entry-flag">example</span>
<time class="entry-time">{{ clock(c.at) }}</time>
</header>

Expand Down Expand Up @@ -228,9 +262,14 @@ const clock = (t: number) =>
.tick--clear{color:var(--settled);}
.tick--clear::before{background:var(--settled);}

.ghost{margin-left:auto;background:transparent;border:1px solid var(--rule);color:var(--ink-dim);border-radius:7px;padding:6px 13px;font:inherit;font-size:13px;cursor:pointer;transition:color .16s,border-color .16s;}
.ghost{background:transparent;border:1px solid var(--rule);color:var(--ink-dim);border-radius:7px;padding:6px 13px;font:inherit;font-size:13px;cursor:pointer;transition:color .16s,border-color .16s;}
.ghost:hover,.ghost:focus-visible{color:var(--ink);border-color:var(--ink-dim);}

.rail-read{flex:1;}
.agentview{margin:18px 0 0;}
.agentview figcaption{margin:0 0 8px;font-size:12.5px;color:var(--ink-dim);max-width:66ch;line-height:1.55;}
.agentview figcaption code{font-family:'JetBrains Mono',monospace;color:var(--decided);}
.entry-flag{font-size:10.5px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:var(--ink-quiet);border:1px solid var(--rule);border-radius:99px;padding:2px 8px;}
.void{margin:0;font-size:15px;line-height:1.7;color:var(--ink-dim);max-width:58ch;text-wrap:pretty;}
.void code{font-family:'JetBrains Mono',monospace;font-size:0.9em;color:var(--ink);}

Expand Down
19 changes: 19 additions & 0 deletions website/.vitepress/theme/__tests__/room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ describe('the Merge Room', { timeout: 30_000 }, () => {
expect(actors).toContain('you')
})

it('marks a seeded case as an example, so nothing on screen pretends to be yours', async () => {
// The page seeds one real case on arrival so a visitor lands on a working
// room. It runs through the same engine call an agent makes, so the only
// thing separating it from real work is this flag and the label it drives.
await resolveConflictTool.execute(
{ content: MIXED, filePath: 'src/app.ts' },
{ signal },
{ example: true },
)
expect((roomCases.value[0] as ConflictCase).example).toBe(true)
expect(roomJournal.value.some((e) => e.text.includes('as an example'))).toBe(true)
})

it('does not mark an ordinary call as an example', async () => {
await resolveConflictTool.execute({ content: MIXED, filePath: 'src/app.ts' }, { signal })
expect((roomCases.value[0] as ConflictCase).example).toBeUndefined()
expect(roomJournal.value.some((e) => e.text.includes('as an example'))).toBe(false)
})

it('files git errors as cases too', async () => {
await parseGitErrorTool.execute({ output: 'fatal: refusing to merge unrelated histories' }, { signal })
expect(summary.value.errors).toBe(1)
Expand Down
14 changes: 11 additions & 3 deletions website/.vitepress/theme/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface ConflictCase {
hunks: HunkView[]
segments: Segment[]
at: number
/** Seeded on arrival so the room is not empty. Labelled as such on screen. */
example?: boolean
}

export interface ErrorCase {
Expand All @@ -51,6 +53,8 @@ export interface ErrorCase {
titles: string[]
body: string
at: number
/** Seeded on arrival so the room is not empty. Labelled as such on screen. */
example?: boolean
}

export type RoomCase = ConflictCase | ErrorCase
Expand All @@ -76,22 +80,26 @@ export function fileConflict(input: {
filePath: string
hunks: HunkView[]
segments: Segment[]
example?: boolean
}): ConflictCase {
const c: ConflictCase = { id: nextId('conflict'), kind: 'conflict', at: Date.now(), ...input }
cases.value = [...cases.value, c]
const settled = input.hunks.filter((h) => h.autoResolved).length
const open = input.hunks.length - settled
log(
'agent',
`Filed ${c.id} (${input.filePath}): ${settled} settled, ${open} waiting on you.`,
input.example
? `Seeded ${c.id} (${input.filePath}) as an example: ${settled} settled, ${open} waiting on you.`
: `Filed ${c.id} (${input.filePath}): ${settled} settled, ${open} waiting on you.`,
)
return c
}

export function fileError(input: { titles: string[]; body: string }): ErrorCase {
export function fileError(input: { titles: string[]; body: string; example?: boolean }): ErrorCase {
const c: ErrorCase = { id: nextId('error'), kind: 'error', at: Date.now(), ...input }
cases.value = [...cases.value, c]
log('agent', `Filed ${c.id}: ${input.titles.join('; ') || 'no known git error matched'}.`)
const what = input.titles.join('; ') || 'no known git error matched'
log('agent', input.example ? `Seeded ${c.id} as an example: ${what}.` : `Filed ${c.id}: ${what}.`)
return c
}

Expand Down
10 changes: 5 additions & 5 deletions website/.vitepress/theme/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const parseGitErrorTool: WebMcpTool = {
},
required: ['output'],
},
async execute({ output }: { output: string }) {
async execute({ output }: { output: string }, _opts?: unknown, meta?: { example?: boolean }) {
if (typeof output !== 'string' || output.trim() === '') {
return text('No output provided. Pass the raw text of the failing git command as `output`.')
}
Expand All @@ -51,7 +51,7 @@ export const parseGitErrorTool: WebMcpTool = {
'',
'Useful next command: `git status` prints the repository state that most git errors are really about.',
].join('\n')
fileError({ titles: [], body })
fileError({ titles: [], body, example: meta?.example })
return text(body)
}

Expand All @@ -77,7 +77,7 @@ export const parseGitErrorTool: WebMcpTool = {
: `${matches.length} known git errors matched this output, listed in the order they should be dealt with.`

const body = truncate([header, ...sections].join('\n\n'))
fileError({ titles: matches.map((m) => m.entry.title), body })
fileError({ titles: matches.map((m) => m.entry.title), body, example: meta?.example })
return text(body)
},
}
Expand All @@ -101,7 +101,7 @@ export const resolveConflictTool: WebMcpTool = {
},
required: ['content'],
},
async execute({ content, filePath }: { content: string; filePath?: string }) {
async execute({ content, filePath }: { content: string; filePath?: string }, _opts?: unknown, meta?: { example?: boolean }) {
if (typeof content !== 'string' || content.trim() === '') {
return text('No content provided. Pass the full conflicted file as `content`.')
}
Expand Down Expand Up @@ -150,7 +150,7 @@ export const resolveConflictTool: WebMcpTool = {
s.type === 'text' ? { kind: 'text' as const, lines: s.lines } : { kind: 'hunk' as const, index: ++n },
)

const filed = fileConflict({ filePath: path, hunks, segments })
const filed = fileConflict({ filePath: path, hunks, segments, example: meta?.example })

const hunkLines = hunks.map(
(h) =>
Expand Down
36 changes: 36 additions & 0 deletions website/.vitepress/theme/tools/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,39 @@ export const SAMPLE_CONFLICT = [
' return server',
'}',
].join('\n')

/**
* A lockfile conflict the format-aware resolver settles end to end, with
* nothing left for a person. Seeded next to the source conflict so the room
* shows both outcomes on arrival rather than only the one needing a human.
*/
export const SAMPLE_LOCKFILE = [
"lockfileVersion: '9.0'",
'',
'importers:',
'',
' .:',
' dependencies:',
'<<<<<<<'.concat(' ours'),
' yaml:',
' specifier: ^2.5.0',
' version: 2.8.3',
'|||||||'.concat(' base'),
' yaml:',
' specifier: ^2.5.0',
' version: 2.8.1',
'=======',
' yaml:',
' specifier: ^2.5.0',
' version: 2.8.3',
'>>>>>>>'.concat(' theirs'),
].join('\n')

/** A git failure with a clear, well-known fix. */
export const SAMPLE_DETACHED_HEAD = [
"Note: switching to 'v3.8.0'.",
'',
"You are in 'detached HEAD' state. You can look around, make experimental",
'changes and commit them, and you can discard any commits you make in this',
'state without impacting any branches by switching back to a branch.',
].join('\n')
Loading