openvibers/openvibe.codes Commits

A conversation's next turn starts from the work before it, on any agent; the workspace is one app (L31 CH4, CH7) (#205)

OP OpenVibers committed 13 days ago · a2eb3e3510 · 10 files, +136 −51

* A conversation's next turn starts from the work and the words before it, on any agent (L31 CH4)

Each turn of a coding conversation ran from a fresh clone with only the brief and the newest note: the earlier turns'
changes and what was said were gone. Now the next turn's job carries the last turn's diff (base_patch, against the
branch the task started from, so it holds every earlier turn) and applies it before the agent starts, saying so, or
saying that it no longer applies; and its brief carries the conversation so far, compact (each turn's agent and the
words it ended with, and the person's notes between turns). A switch to another agent carries the person's message too,
and the thread shows both the message and the hand-over.

* The workspace is one app: Tasks, Agents, Machines, Usage, Settings, the conductor and Approvals beside the rail (L31 CH7)

Every page of a signed-in person's workspace renders in the chat app's shell: the rail of conversations on the left
(marking the page the person is on at its foot), the page where the conversation would be. The rail is the one
navigation: the navbar keeps only the product's places in More, and phones get the rail as a drawer instead of a tab
bar.

Changes

ddiff --git a/harness/job.js b/harness/job.js
iindex 136a838..8e90a06 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -169,6 +169,8 @@ function parseArgs(raw, env = process.env) {
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
...(effort ? { effort } : {}),
++ // The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
++ ...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
};
}
@@@ -465,6 +467,20 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
result.base = base;
say({ t: 'step', text: `Working on ${base}` });
++ // A turn of a conversation starts from the work of the turns before it. A diff that no longer applies
++ // (the branch moved under it) is said, and the turn starts from the branch as it is.
++ if (job.base_patch) {
++ const file = path.join(dir, 'conversation.patch');
++ fs.writeFileSync(file, job.base_patch.endsWith('\n') ? job.base_patch : `${job.base_patch}\n`);
++ try {
++ await git(['apply', '--3way', '--whitespace=nowarn', file], { cwd: work });
++ result.base_patch = 'applied';
++ say({ t: 'step', text: 'Picked up the changes from the earlier turns' });
++ } catch (e) {
++ result.base_patch = 'failed';
++ say({ t: 'note', level: 'warning', text: 'The earlier turns\' changes no longer apply to the branch; this turn starts from the branch as it is.' });
++ }
++ }
} else if (job.kind === 'conductor') {
// The owner's own folder: never a clone, never touched by git. Claude Code opens it, so it loads that
// project's memory and AGENTS.md.
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex cc1d5a4..cc7f172 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -33,6 +33,7 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}
.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}
.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}
.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}
++.ch-rail-foot a.is-here{background:var(--ch-hover);color:var(--ch-accent)}
/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
@@@ -185,6 +186,13 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-recent{margin-top:2px}
.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
++/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and
++ keep their own heading, so the bar above them is only the phone's way to the rail. */
++.ch-page{flex:1;min-height:0;overflow-y:auto}
++.ch-page .c4{max-width:none;padding:0 max(20px,calc((100% - 980px) / 2)) 44px}
++.ch.is-page .ch-head{display:none}
++@media (max-width:760px){.ch.is-page .ch-head{display:flex;border-bottom:0;min-height:44px}.ch-page .c4{padding:0 14px 32px}.ch-page .c4-main{padding-top:6px}}
++
/* ── The side: what it did ── */
.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}
.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex f1ba425..1d12d68 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -804,15 +804,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));
if (live) return { attempt: live, live: true, row };
const text = note ? String(note).trim().slice(0, 4000) : '';
++ const lastResult = asObject(row.result) || {};
++ const basePatch = typeof lastResult.patch === 'string' && lastResult.patch && !lastResult.patch_truncated ? lastResult.patch : null;
++ const soFar = await conversationSoFar(row);
const plan = agent
? {
-- kind: 'switch', agent, model: model || null, effort: effort || null, session: null,
-- prompt: `${row.prompt}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n`,
-- why: `${attemptStore.nameOf(agent)} took over at the owner's request`,
++ kind: 'switch', agent, model: model || null, effort: effort || null, session: null, basePatch,
++ prompt: `${row.prompt}${soFar}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n${text ? `\n---\n## A note from the owner\n\n${text}\n` : ''}`,
++ why: text ? `carried on by the owner: ${text.slice(0, 200)}` : `${attemptStore.nameOf(agent)} took over at the owner's request`,
}
: {
-- kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null,
-- prompt: `${row.prompt}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,
++ kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null, basePatch,
++ prompt: `${row.prompt}${soFar}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,
why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',
};
let attempt = null;
@@@ -865,12 +868,42 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
/** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */
++ /**
++ * The conversation so far (Codes L31 CH4), for the next turn's brief: each earlier turn's agent and the words it
++ * ended with, and what the person said between turns. Compact (each answer cut to 1 200 characters, the whole to
++ * about 6 000), so any agent can pick the conversation up without its predecessor's session; the work itself
++ * travels as the diff (base_patch).
++ */
++ async function conversationSoFar(row) {
++ const attempts = await attemptStore.list(row.id);
++ if (!attempts.length) return '';
++ const ends = await db.many(
++ `SELECT DISTINCT ON (attempt_n) attempt_n, body FROM codes_session_lines
++ WHERE task_id = $1 AND kind IN ('done', 'text') ORDER BY attempt_n, (kind = 'done') DESC, seq DESC`, [row.id],
++ ).catch(() => []);
++ const said = new Map(ends.map((l) => [Number(l.attempt_n), String((l.body && l.body.text) || '').trim()]));
++ const parts = [];
++ for (const a of attempts) {
++ const note = /^carried on by the owner: /.test(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
++ if (note && Number(a.n) > 1) parts.push(`**The owner:** ${note}`);
++ const text = said.get(Number(a.n));
++ const who = attemptStore.nameOf(a.agent);
++ parts.push(`**Turn ${a.n} · ${who}${a.state && a.state !== 'done' ? ` (${attemptStore.wordsOf(a.reason || a.state)})` : ''}:** ${text ? (text.length > 1200 ? `${text.slice(0, 1200)}…` : text) : 'it ended without a word.'}`);
++ }
++ let out = parts.join('\n\n');
++ if (out.length > 6000) out = `…${out.slice(-6000)}`;
++ return `\n\n---\n## The conversation so far (written by openvibe.codes)\n\n${out}\n\nThe changes from these turns are already in your working tree.\n`;
++ }
++
function followUpArgs(row, plan) {
return parseArgs(JSON.stringify({
repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,
agent: plan.agent || null, permission: 'edit',
...(plan.model ? { model: plan.model } : {}),
...(plan.effort ? { effort: plan.effort } : {}),
++ // The work so far: the last turn's diff (against the branch the task started from, so it holds every
++ // earlier turn's changes too). A diff cut to fit the result channel would not apply, so it is not sent.
++ ...(plan.basePatch ? { base_patch: plan.basePatch } : {}),
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(plan.session ? { resume: plan.session } : {}),
}));
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex fd9e96e..865426a 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -72,6 +72,10 @@ function createC4Routes(ctx) {
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
++ if (viewer(req) && ctx.chatShell && !extra.index) {
++ const here = '/' + (String(req.path || '/').split('/')[1] || '');
++ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css: ['css/c4.css'], scripts: ['js/c4.js'], here });
++ }
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
};
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 7264cc5..514750c 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -136,7 +136,7 @@ function createChatRoutes(ctx) {
FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],
);
}
-- function railHtml(rows, currentId, waitingIds) {
++ function railHtml(rows, currentId, waitingIds, here = '') {
const now = new Date();
const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const bucket = (row) => {
@@@ -160,7 +160,7 @@ function createChatRoutes(ctx) {
groups[groups.length - 1][1].push(row);
}
const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]
-- .map(([label, href, i]) => `<a href="${href}" title="${label}">${ico(i)}<span>${label}</span></a>`).join('');
++ .map(([label, href, i]) => `<a href="${href}" title="${label}"${here === href ? ' class="is-here" aria-current="page"' : ''}>${ico(i)}<span>${label}</span></a>`).join('');
return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">
<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>
<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>
@@@ -222,9 +222,9 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
const n = Number(a.n);
if (prev) {
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
-- if (a.kind === 'manual' && note) out.push(you(note));
-- else if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));
-- else if (a.why) out.push(divider(String(a.why), 'handover'));
++ if (note) out.push(you(note));
++ if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));
++ else if (a.why && !note) out.push(divider(String(a.why), 'handover'));
}
const isLive = live && n === Number(attempts[attempts.length - 1].n);
out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));
@@@ -312,21 +312,21 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla
}
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
-- async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false }) {
++ async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false, page = null, here = '', css = [], scripts = [] }) {
const rows = await conversations(req.viewer.subject);
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waitingIds = new Set(waiting.map((a) => a.task_id));
-- const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}" data-chat>
--${railHtml(rows, currentId, waitingIds)}
++ const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>
++${railHtml(rows, currentId, waitingIds, here)}
<div class="ch-scrim" data-ch-scrim hidden></div>
<section class="ch-main" aria-label="Conversation">
<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
--<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
--${composerHtml}
++${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
++${composerHtml}`}
</section>
${side}
</div>`;
-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: ['css/chat.css'], scripts: ['js/chat.js'], body: raw(body) });
++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
}
/** The side: who worked on it, and where the work is. */
@@@ -347,6 +347,10 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
</aside>`;
}
++ ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {
++ title, page: body, here, css, scripts, head: '',
++ });
++
// A new conversation: the greeting, the repository and the agent, and the composer.
r.get('/', async (req, res, next) => {
if (!viewer(req)) return next();
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 11f2341..0f2c96f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -79,16 +79,12 @@ function renderPage(o) {
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
-- // A conversation (a signed-in /, /tasks/:id) is the chat app: its rail is the navigation, so the bar carries none.
-- const chatPage = signedIn && (pathOnly === '/' || /^\/tasks\/tsk_[0-9A-Z]{26}$/.test(pathOnly));
-- const links = chatPage ? []
-- : (signedIn && c4Workspace)
-- ? [
-- ...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),
-- // The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the
-- // product's one is named for what it is.
-- ...NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })),
-- ]
++ // A signed-in person's workspace is one app (Codes L31): its rail is the navigation (the conversations, and the
++ // workspace's own pages at its foot), so the navbar keeps only the product's places, in More. The catalogue of
++ // harnesses is named for what it is, beside the workspace's own Agents page in the rail.
++ const inApp = signedIn && c4Workspace;
++ const links = inApp
++ ? NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label }))
: NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));
const nav = {
service: 'codes',
@@@ -98,7 +94,8 @@ function renderPage(o) {
// Phones: one tab bar, the Frame's own (Shared 4.20). On a workspace page a signed-in person gets the workspace's
// sections there — the first four (Home, Tasks, Conductor, Agents) and More, which opens the navbar's drawer with
// the rest; the section the person is in is marked. Everyone else keeps the product's four.
-- appBar: c4Workspace && signedIn ? true : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],
++ // Phones: in the app the rail is a drawer and the conversation owns the screen, so no tab bar (L31).
++ appBar: inApp ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/test/attempts.test.js b/test/attempts.test.js
iindex 3510bb1..6a6ad5b 100644
---- a/test/attempts.test.js
++++ b/test/attempts.test.js
@@@ -294,6 +294,19 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');
});
++ await check('the next turn carries the work and the conversation so far, to the same agent or another (L31 CH4)', async () => {
++ const id = await startTask();
++ const patch = 'diff --git a/cache.js b/cache.js\nnew file mode 100644\n--- /dev/null\n+++ b/cache.js\n@@ -0,0 +1 @@\n+module.exports = new Map();\n';
++ await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'I added the cache.', files: [{ path: 'cache.js', added: 1, removed: 0 }], patch, log: [{ t: 'done', ok: true, text: 'I added the cache.' }] } });
++ const continued = await call('POST', `/tasks/${id}/continue`, { body: { note: 'Now the tests.' } });
++ assert.strictEqual(continued.status, 200, JSON.stringify(continued.json));
++ const submits = calls.filter((c) => c[0] === 'submit');
++ const args = submits[submits.length - 1][1].args;
++ assert.strictEqual(args.base_patch, patch, 'the work so far travels as the diff');
++ assert.ok(args.prompt.includes('## The conversation so far') && args.prompt.includes('Now the tests.'), 'and the conversation as words');
++ assert.ok(args.prompt.indexOf('## The conversation so far') < args.prompt.indexOf('Now the tests.'), 'the history before the new note');
++ });
++
await check('the owner needs no token: their own task reads, continues and stops', async () => {
const id = await startTask();
const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex ff0afdb..f7b1f4c 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
-- assert.ok(page.text.includes('"appBar":true') && !page.text.includes('c4-mobile'), 'the workspace gives the Frame app bar its own sections: no second bar');
++ assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ch-rail-open'), 'the app has no tab bar: the rail is its navigation (L31)');
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
-- assert.ok(coded.text.includes('"appBar":true'), 'a query string does not bring a second bar back');
++ assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ch-rail-open'), 'a query string keeps the page in the app');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
const wrong = await t.get('/machines?code=nope', { as: ada });
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node&rsquo;s own words');
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
iindex c499cd2..4d58531 100644
---- a/test/c4c.test.js
++++ b/test/c4c.test.js
@@@ -30,20 +30,15 @@ const split = (links) => ({
const t = await boot();
const ada = t.network.addUser('c4c-ada');
try {
-- await check('a signed-in person&rsquo;s workspace page has one navigation: the Frame navbar&rsquo;s sections', async () => {
-- const page = await t.get('/tasks', { as: ada });
++ await check('a signed-in person&rsquo;s workspace is one app: the rail is its navigation (L31)', async () => {
++ const page = await t.get('/agents', { as: ada });
assert.strictEqual(page.status, 200);
-- const links = navbarOf(page.text).links;
-- const { top, more } = split(links);
-- assert.deepStrictEqual(top, ['Home', 'Tasks', 'Conductor', 'Agents', 'Machines', 'Memory', 'Usage', 'Settings'], 'the sections are the navbar');
-- assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product&rsquo;s places fold into More');
-- assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');
-- const tasks = links.find((l) => l.href === '/tasks');
-- assert.ok(tasks && tasks.active, 'the section the person is in is active');
-- assert.ok(!links.find((l) => l.href === '/agents').active, 'and no other one is');
-- // The page carries no sidebar of its own: the Frame owns the navigation.
-- assert.ok(!page.text.includes('c4-side'), 'no second navigation on the page');
-- assert.ok(!page.text.includes('c4-layout'), 'and no sidebar column');
++ const { top, more } = split(navbarOf(page.text).links);
++ assert.deepStrictEqual(top, [], 'the navbar carries no sections');
++ assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product&rsquo;s places, in More');
++ assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail is on the page');
++ assert.ok(/<a href="\/agents" title="Agents" class="is-here" aria-current="page">/.test(page.text), 'and marks the page the person is on');
++ assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
});
await check('the section that is not the person&rsquo;s own &mdash; the product&rsquo;s catalogue &mdash; is named for what it is', async () => {
@@@ -70,19 +65,18 @@ const split = (links) => ({
assert.ok(!top.includes('Machines'), 'and the workspace&rsquo;s sections are not');
});
-- await check('the phone page reserves the Frame app bar&rsquo;s height, and has no bar of its own', async () => {
++ await check('on a phone the app has no tab bar: the rail is a drawer, and the page reserves no bar&rsquo;s height', async () => {
const page = await t.get('/settings', { as: ada });
-- assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');
-- assert.ok(!page.text.includes('c4-mobile'), 'and it draws no second bar of its own');
-- const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');
-- assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--ovnav-appbar-h,calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)\)/.test(css),
-- 'the page reserves the Frame bar&rsquo;s height, its safe area included');
++ assert.strictEqual(navbarOf(page.text).appBar, false, 'no Frame tab bar in the app');
++ assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
++ assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');
++ assert.ok(page.text.includes('data-ch-rail-open'), 'the rail opens from the page&rsquo;s own header');
});
-- await check('the Frame&rsquo;s app bar is the workspace&rsquo;s sections for a signed-in person, and the product&rsquo;s for a visitor', async () => {
++ await check('the Frame&rsquo;s app bar: none in a signed-in person&rsquo;s app, the product&rsquo;s for a visitor', async () => {
const signedIn = await t.get('/tasks', { as: ada });
-- assert.strictEqual(navbarOf(signedIn.text).appBar, true,
-- 'the bar takes the navbar&rsquo;s own sections: the first four and More (Shared appbar.js)');
++ assert.strictEqual(navbarOf(signedIn.text).appBar, false, 'in the app the rail is the navigation, not a tab bar');
const visitor = await t.get('/tasks');
const bar = navbarOf(visitor.text).appBar;
assert.ok(Array.isArray(bar) && bar.length <= 5, 'a visitor&rsquo;s bar is at most five places');
ddiff --git a/test/job.test.js b/test/job.test.js
iindex 0e3eeff..87fe5cf 100644
---- a/test/job.test.js
++++ b/test/job.test.js
@@@ -114,6 +114,22 @@ function fakeHarness({ fail = false } = {}) {
assert.strictEqual(fs.readFileSync(path.join(src.dir, 'README.md'), 'utf8'), '# Demo\n');
});
++ await check('a conversation\'s next turn starts from the work of the turns before it (L31 CH4)', async () => {
++ const src = repository();
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-tmp-'));
++ const turn = (extra) => runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add a line', ...extra }), harness: fakeHarness(), emit: () => {}, writeResult: () => {}, env, tmp });
++ const first = await turn({});
++ assert.ok(first.patch && first.patch.includes('+Added by the agent.'), 'the first turn\'s diff');
++ const lines = [];
++ const second = await runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add another', base_patch: first.patch }), harness: fakeHarness(), emit: (l) => lines.push(JSON.parse(l)), writeResult: () => {}, env, tmp });
++ assert.strictEqual(second.base_patch, 'applied', 'the earlier work was picked up');
++ assert.strictEqual((second.patch.match(/\+Added by the agent\./g) || []).length, 2, 'and the new diff holds both turns');
++ assert.ok(lines.some((l) => /Picked up the changes from the earlier turns/.test(JSON.stringify(l))), 'and says so');
++ const stale = await turn({ base_patch: 'diff --git a/NOPE.md b/NOPE.md\n--- a/NOPE.md\n+++ b/NOPE.md\n@@ -1 +1 @@\n-x\n+y\n' });
++ assert.strictEqual(stale.base_patch, 'failed', 'a diff that no longer applies is said');
++ assert.ok(stale.patch && stale.patch.includes('+Added by the agent.'), 'and the turn still runs from the branch as it is');
++ });
++
await check('a push is only ever a new branch: never the base, a protected name, an existing branch, or unchecked', () => {
const { pushTarget } = require('../harness/job');
assert.deepStrictEqual(pushTarget('fix/typo', 'main'), { branch: 'fix/typo' });