diff --git a/README.md b/README.md index c0921f8..5ad1458 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ contribution. This app is the dashboard for that loop: its cache, but dismissing waits until you're back online). Records staged by an older agent without a review plan still show the plain card with both buttons. + Dependent PRs can be prepared as a **stack**: Contribute renders their + `base → branch` topology as one linked review, keeps every incremental + body and diff independently inspectable, and uses a second explicit + confirmation to publish the enumerated chain parent-first. True stacks + use dedicated upstream `stack/**` branches and therefore require upstream + push permission; independent contributions continue through the safer + reusable-fork path. - **Open** — PRs and issues live on GitHub, plus anything the agent is submitting right now. State is refreshed on open; the daily background job also checks for comments, reviews, and failing checks that need follow-up. @@ -120,7 +127,15 @@ shape: "body_draft": "…", // the exact text that would go public "branch": "…", "repo_path": "…", "base_sha": "…", "head_sha": "…", "diff_sha256": "…", - "diff_stat": "…" // diff_excerpt is legacy — omit it + "diff_stat": "…", // diff_excerpt is legacy — omit it + "stack": { // optional: one complete 2–12 PR chain + "id": "notes-flow", + "name": "Notes flow", + "position": 2, + "total": 3, + "parent_record_id": "notes-flow-01", + "base_branch": "stack/notes-flow/01-model" + } } } ``` diff --git a/api.js b/api.js index caa23e0..b0e6a7b 100644 --- a/api.js +++ b/api.js @@ -157,3 +157,44 @@ export async function submitContribution({ appId, token, rec }) { return { error: String((err && err.message) || err) } } } + +// Batch approval path for one immutable PR stack. recordIds is the exact +// ordered list rendered in the confirmation, so the server cannot silently +// include a layer the partner did not review. The response always carries the +// latest known records, including partial success after a durable retry. +export async function submitContributionStack({ appId, token, recordIds }) { + try { + const r = await fetch( + '/api/github/contributions/' + encodeURIComponent(appId) + '/submit-stack', + { + method: 'POST', + headers: { ...authHeaders(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ record_ids: recordIds }), + } + ) + let body = null + try { body = await r.json() } catch { body = null } + if (r.ok) { + return { + ok: Array.isArray(body?.records) ? body.records : [], + submitted: Array.isArray(body?.submitted) ? body.submitted : [], + } + } + const detail = body?.detail + if (detail && typeof detail === 'object') { + return { + error: detail.message || 'Could not submit this PR stack.', + records: Array.isArray(detail.records) ? detail.records : [], + submitted: Array.isArray(detail.submitted) ? detail.submitted : [], + } + } + return { + error: typeof detail === 'string' ? detail : 'Could not submit this PR stack.', + } + } catch (err) { + if (window.mobius && window.mobius.online === false) { + return { error: 'You are offline — publishing a PR stack needs a connection.' } + } + return { error: String((err && err.message) || err) } + } +} diff --git a/contributing.md b/contributing.md index c92b1b7..d179183 100644 --- a/contributing.md +++ b/contributing.md @@ -138,6 +138,7 @@ Contribute needs to submit it directly after approval: plan: {action: pr|issue|issue_comment|discussion_comment, # mirrors record.type repo, target_url?, title?, body_draft, branch?, repo_path?, base_sha?, head_sha?, diff_sha256?, diff_stat, + stack?: {id, name?, position, total, parent_record_id, base_branch}, diff_excerpt?} # diff_stat REQUIRED; diff_excerpt legacy (unused) ``` @@ -199,6 +200,29 @@ record, and stop again. A record flipped to `abandoned` means the partner dropped it — never argue with one, never resurrect it unasked. +### The green light for a PR stack + +When 2–12 prepared PR records carry one complete `plan.stack` chain, +Contribute groups them into one visual review and shows **Send N-PR stack**. +The second, explicit confirmation lists every title and `base → branch` pair; +that one click approves exactly those enumerated pushes and PR creations. + +Before the first public push, the platform rechecks every record, every stored +diff, every parent SHA, the full branch topology, commit attribution, and the +whole stack's ability to merge with current upstream. It then publishes the +branches and opens the PRs from parent to child. If a later layer fails after a +parent PR was already created, the successful record remains open and every +unsent record returns to `prepared` with the durable error — retry never hides +the partial public state. + +**True stacks require upstream push permission.** GitHub cannot use a branch +that exists only in the contributor's fork as the base of a PR in the upstream +repository. The stack path therefore publishes dedicated `stack/**` branches +directly to upstream, and the server refuses before pushing anything unless the +connected owner has `permissions.push` there. Without that permission, prepare +independent fork PRs instead; never simulate a stack by publishing a cumulative +diff that differs from the reviewed `.diff`. + --- ## Prepare the branch @@ -206,6 +230,42 @@ one, never resurrect it unasked. Run these during preparation, after the partner agrees to stage a PR for review. Do not fork, push, or create a PR here. +### Prepare a linked PR stack + +Use a stack only when the changes have a real dependency or review order. Each +layer is its own reviewed commit and its `.diff` is **incremental against the +previous layer**, never the cumulative diff against `main`. + +1. Choose one privacy-safe stack id, for example `chat-settlement`. Every branch + must start `stack//`, followed by an ordered descriptive suffix: + `stack/chat-settlement/01-runtime`, `.../02-ui`, `.../03-tests`. +2. Prepare layer 1 from the current upstream/default base SHA. Prepare layer 2 + from layer 1's exact `head_sha`, and so on. Use one durable linked worktree + per record under `/data/contrib//worktree`. +3. Set the connected owner's repo-local author/committer identity **before every + commit**. Standalone send can normalize one tip commit; stack send cannot + rewrite a parent without invalidating every child's reviewed ancestry. +4. Store the canonical `base_sha..head_sha` diff and hash for each layer exactly + as for a standalone PR. +5. Put this additive object in every plan (positions are 1-based and complete): + +```json +"stack": { + "id": "chat-settlement", + "name": "Chat settlement", + "position": 2, + "total": 3, + "parent_record_id": "chat-settlement-01", + "base_branch": "stack/chat-settlement/01-runtime" +} +``` + +Layer 1 has an empty `parent_record_id` and `base_branch` equal to upstream's +default branch (normally `main`). Every later `parent_record_id` names the +immediately preceding ledger record, `base_branch` equals that record's branch, +and its `base_sha` equals that record's `head_sha`. Re-read all records and diffs +as one review unit before saying the stack is ready. + ### An app with a real origin (most catalog apps) `git -C /data/apps/ remote get-url origin` succeeds → work in the app's own diff --git a/index.jsx b/index.jsx index 9a20226..b2d12a4 100644 --- a/index.jsx +++ b/index.jsx @@ -28,6 +28,7 @@ import { fetchLiveStates, fetchSourceStatus, submitContribution, + submitContributionStack, } from './api.js' import { StatTiles } from './ui/StatTiles.jsx' import { ConnectionCard } from './ui/ConnectionCard.jsx' @@ -70,7 +71,7 @@ function Header({ appId, fromCache }) {

Contribute

- Your local work and its path upstream + Review work and see where it sits upstream {fromCache && ( Offline — showing your last synced feed. )} @@ -102,12 +103,13 @@ export default function ContributeApp({ appId, token }) { const [conn, setConn] = useState({ state: 'unknown' }) const [loading, setLoading] = useState(true) const [view, setViewState] = useState(() => { - try { return sessionStorage.getItem('contribute-view-v1') || 'sources' } - catch { return 'sources' } + try { return sessionStorage.getItem('contribute-view-v2') || 'contributions' } + catch { return 'contributions' } }) const [sourceSnapshot, setSourceSnapshot] = useState(null) const [sourceLoading, setSourceLoading] = useState(true) const [sourceError, setSourceError] = useState('') + const pageRef = useRef(null) // Latest records for callbacks (the connect-flow refresh) that must not take // a `records` dependency and re-bind on every ledger change. const recordsRef = useRef(records) @@ -245,13 +247,21 @@ export default function ContributeApp({ appId, token }) { const setView = useCallback((next) => { setViewState(next) - try { sessionStorage.setItem('contribute-view-v1', next) } catch { /* optional */ } + try { sessionStorage.setItem('contribute-view-v2', next) } catch { /* optional */ } }, []) + // Contributions is one long reading feed; Repository map owns two internal + // panes on desktop. Reset the shared page scroller at the boundary so a deep + // feed position never shifts the map header or couples the two scroll modes. + useEffect(() => { + pageRef.current?.scrollTo({ top: 0, left: 0 }) + }, [view]) + // Send = direct PR submit. The platform claims the prepared record, - // recomputes the branch diff, safely fast-forwards a stale reusable fork, - // pushes to it, opens the PR, and returns the updated ledger record. On a - // partner-actionable failure the + // recomputes the branch diff, adapts it to a strictly-behind reusable fork + // without changing the fork's default branch, pushes the topic branch, opens + // the PR, and returns the updated ledger record. On a partner-actionable + // failure the // server rolls the record back to `prepared` with last_submit_error, and the // card stays ready for feedback/retry instead of handing off to an agent chat. const onSend = useCallback(async (rec) => { @@ -274,6 +284,39 @@ export default function ContributeApp({ appId, token }) { return { error: outcome.error || 'Could not submit this PR.' } }, [appId, token]) + // One explicit confirmation can publish an exact, already-reviewed chain. + // The response may contain partial progress (for example, parent opened and + // child creation bounced), so merge every returned ledger record rather + // than treating the stack as all-or-nothing after public work has begun. + const onSendStack = useCallback(async (stackRecords) => { + const outcome = await submitContributionStack({ + appId, + token, + recordIds: stackRecords.map((rec) => rec.id), + }) + const updates = outcome.ok || outcome.records || [] + if (updates.length > 0) { + const byId = new Map(updates.map((rec) => [rec.id, rec])) + setRecords((prev) => { + const next = prev.map((rec) => { + const update = byId.get(rec.id) + return update ? { ...update, path: rec.path } : rec + }) + recordsRef.current = next + cacheFeed(next) + return next + }) + } + if (outcome.ok) { + window.mobius?.signal?.('contribution_stack_submitted', { + stack_id: stackRecords[0]?.plan?.stack?.id || '', + item_count: outcome.submitted?.length || 0, + }) + return { ok: true, submitted: outcome.submitted?.length || 0 } + } + return { error: outcome.error || 'Could not submit this PR stack.' } + }, [appId, token]) + // Feedback = return to the chat that created the contribution, with a small // draft already pointing at the exact record. Attention follow-ups can pass // a more specific draft. Older records may not have @@ -341,33 +384,34 @@ export default function ContributeApp({ appId, token }) { const groups = useMemo(() => groupRecords(records), [records]) const isEmpty = records.length === 0 const sourceCount = sourceSnapshot - ? 1 + (sourceSnapshot.apps?.length || 0) + ? [sourceSnapshot.platform, ...(sourceSnapshot.apps || [])] + .filter((source) => source?.available).length : null const activeCount = stats.open + stats.ready return (
-
+
@@ -390,7 +434,9 @@ export default function ContributeApp({ appId, token }) { {loading ? null : isEmpty ? : ( 0 + const comparisonTree = project?.origin?.local_tree || project?.tree + const authoredFiles = Number( + comparisonTree?.authored_files ?? comparisonTree?.files ?? 0, + ) + const managedFiles = Number(comparisonTree?.managed_files || 0) + const originAhead = Number(project?.origin?.local_ahead ?? project?.ahead ?? 0) + const originBehind = Number(project?.origin?.local_behind ?? project?.behind ?? 0) + const different = authoredFiles > 0 + const forks = projectForks(project, contributions) const contributionAttention = contributions.some((rec) => rec.needs_attention) const attention = ( project?.state === 'conflict' || project?.state === 'diverged' || + originBehind > 0 || workingFiles > 0 || contributionAttention ) @@ -85,28 +94,66 @@ function decorateProject(project, contributions) { contributions, contributionCounts: { ready, open }, different, + adapted: managedFiles > 0, + authoredFiles, + managedFiles, + comparisonTree, + originAhead, + originBehind, + forks, workingFiles, attention, } } +export function projectForks(project, contributions = []) { + const forks = new Map() + for (const fork of project?.forks || []) { + const key = repoKey(fork?.repo) + if (!key) continue + forks.set(key, { ...fork, repo: fork.repo, contributions: [] }) + } + for (const rec of contributions) { + const repo = rec?.head_repository + const key = repoKey(repo) + if (!key || key === repoKey(project?.canonical_repo)) continue + const existing = forks.get(key) || { + repo, + ref: null, + sha: null, + ahead: null, + behind: null, + tree: null, + contributions: [], + } + existing.contributions.push(rec) + if (!existing.sha && rec.last_submit_fork_sha) existing.sha = rec.last_submit_fork_sha + if (!existing.branch && rec.last_submit_fork_branch) { + existing.branch = rec.last_submit_fork_branch + } + if (!existing.sync && rec.last_submit_fork_sync) existing.sync = rec.last_submit_fork_sync + forks.set(key, existing) + } + return [...forks.values()].sort((a, b) => a.repo.localeCompare(b.repo)) +} + export function sourceSummary(projects) { return (projects || []).reduce((out, project) => { out.sources += 1 out.different += Number(project.different) out.working += Number(project.workingFiles || 0) out.active += project.contributions?.length || 0 + out.forks += project.forks?.length || 0 + out.adapted += Number(project.adapted) out.attention += Number(project.attention) return out - }, { sources: 0, different: 0, working: 0, active: 0, attention: 0 }) + }, { sources: 0, different: 0, working: 0, active: 0, forks: 0, adapted: 0, attention: 0 }) } export function projectMatchesFilter(project, filter) { if (filter === 'attention') return project.attention - if (filter === 'different') return project.different - if (filter === 'working') return project.workingFiles > 0 - if (filter === 'prs') return project.contributions.length > 0 - if (filter === 'aligned') return project.state === 'aligned' + if (filter === 'changed') return project.different || project.workingFiles > 0 + if (filter === 'shared') return project.contributions.length > 0 || project.forks.length > 0 return true } @@ -120,6 +167,10 @@ export function projectStatus(project) { if (project.workingFiles > 0) { return { label: project.workingFiles + ' working', tone: 'warn' } } + if (project.originBehind > 0 && project.originAhead > 0) { + return { label: 'Both sides changed', tone: 'warn' } + } + if (project.originBehind > 0) return { label: 'Origin ahead', tone: 'accent' } if (project.state === 'diverged') { return { label: 'Both sides changed', tone: 'warn' } } @@ -127,7 +178,8 @@ export function projectStatus(project) { return { label: project.detached ? 'Detached' : project.branch, tone: 'warn' } } if (project.state === 'incoming') return { label: 'Update available', tone: 'accent' } - if (project.state === 'customized') return { label: 'Different', tone: 'accent' } + if (project.state === 'customized' || project.different) return { label: 'Different', tone: 'accent' } + if (project.state === 'adapted') return { label: 'Install-managed', tone: 'quiet' } if (project.state === 'local_only') return { label: 'Local only', tone: 'quiet' } return { label: 'Aligned', tone: 'ok' } } @@ -152,7 +204,11 @@ export function contributionRelationship(rec, project) { export function formatSourceDelta(project) { const tree = project?.tree if (!tree?.available) return 'Source comparison unavailable' - if (!tree.files) return 'Source trees match' - const files = tree.files + (tree.files === 1 ? ' file differs' : ' files differ') - return files + ' · +' + tree.insertions + ' −' + tree.deletions + const authored = Number(tree.authored_files ?? tree.files ?? 0) + const managed = Number(tree.managed_files || 0) + if (!authored && !managed) return 'Source trees match' + const parts = [] + if (authored) parts.push(authored + (authored === 1 ? ' source file' : ' source files')) + if (managed) parts.push(managed + ' install-managed') + return parts.join(' · ') } diff --git a/stack.js b/stack.js new file mode 100644 index 0000000..462dcf4 --- /dev/null +++ b/stack.js @@ -0,0 +1,102 @@ +// Pure PR-stack helpers shared by the review feed and Sources visualization. +// Stack metadata is intentionally additive: older contribution records remain +// standalone, while a complete plan.stack object opts into ordered rendering +// and the batch submit endpoint. + +export function stackMeta(rec) { + const stack = rec?.plan?.stack + if (!stack || typeof stack !== 'object') return null + const id = typeof stack.id === 'string' ? stack.id.trim() : '' + const position = Number(stack.position) + const total = Number(stack.total) + const baseBranch = typeof stack.base_branch === 'string' + ? stack.base_branch.trim() + : '' + if (!id || !Number.isInteger(position) || !Number.isInteger(total)) return null + if (total < 2 || position < 1 || position > total || !baseBranch) return null + return { + id, + name: typeof stack.name === 'string' && stack.name.trim() + ? stack.name.trim() + : id, + position, + total, + baseBranch, + parentRecordId: typeof stack.parent_record_id === 'string' + ? stack.parent_record_id + : '', + } +} + +export function sortStackRecords(records) { + return [...(records || [])].sort((a, b) => { + const left = stackMeta(a)?.position || Number.MAX_SAFE_INTEGER + const right = stackMeta(b)?.position || Number.MAX_SAFE_INTEGER + return left - right + }) +} + +export function groupContributionUnits(records) { + const units = [] + const stacks = new Map() + for (const rec of records || []) { + const meta = stackMeta(rec) + if (!meta) { + units.push({ type: 'record', id: rec.id, record: rec, records: [rec] }) + continue + } + let unit = stacks.get(meta.id) + if (!unit) { + unit = { + type: 'stack', + id: meta.id, + name: meta.name, + total: meta.total, + records: [], + } + stacks.set(meta.id, unit) + units.push(unit) + } + unit.records.push(rec) + unit.total = Math.max(unit.total, meta.total) + } + for (const unit of units) { + if (unit.type === 'stack') unit.records = sortStackRecords(unit.records) + } + return units +} + +// Ready stacks may have an already-open parent after a durable partial retry. +// Include every known layer in that stack so the confirmation still names the +// complete chain, while standalone prepared records remain individual cards. +export function preparedContributionUnits(ready, allRecords) { + const readyStackIds = new Set( + (ready || []).map(stackMeta).filter(Boolean).map((meta) => meta.id) + ) + const stackRecords = (allRecords || []).filter((rec) => { + const meta = stackMeta(rec) + return meta && readyStackIds.has(meta.id) && + ['prepared', 'open', 'merged'].includes(rec.status) + }) + const stackUnits = groupContributionUnits(stackRecords) + .filter((unit) => unit.type === 'stack') + const standalone = (ready || []) + .filter((rec) => !stackMeta(rec)) + .map((rec) => ({ type: 'record', id: rec.id, record: rec, records: [rec] })) + return [...stackUnits, ...standalone].sort((a, b) => { + const aRec = a.records.find((rec) => rec.status === 'prepared') || a.records[0] + const bRec = b.records.find((rec) => rec.status === 'prepared') || b.records[0] + return String(bRec?.updated_at || bRec?.created_at || '').localeCompare( + String(aRec?.updated_at || aRec?.created_at || '')) + }) +} + +export function stackProgress(unit) { + const records = unit?.records || [] + return { + ready: records.filter((rec) => rec.status === 'prepared').length, + open: records.filter((rec) => ['submitting', 'draft', 'open'].includes(rec.status)).length, + merged: records.filter((rec) => rec.status === 'merged').length, + total: unit?.total || records.length, + } +} diff --git a/test/source-map.test.mjs b/test/source-map.test.mjs index c2cbe40..6aea77c 100644 --- a/test/source-map.test.mjs +++ b/test/source-map.test.mjs @@ -5,6 +5,7 @@ import { contributionRelationship, formatSourceDelta, projectMatchesFilter, + projectForks, projectStatus, sourceSummary, } from '../source-map.js' @@ -45,7 +46,7 @@ test('tree equality wins over bookkeeping-only ahead history', () => { assert.equal(contribute.ahead, 1) assert.equal(contribute.different, false) assert.equal(projectStatus(contribute).label, 'Aligned') - assert.equal(projectMatchesFilter(contribute, 'different'), false) + assert.equal(projectMatchesFilter(contribute, 'changed'), false) }) test('active records for an uninstalled repo stay visible', () => { @@ -68,7 +69,46 @@ test('attention and relationship labels preserve real PR head topology', () => { assert.equal(contributionRelationship(project.contributions[0], project), 'Published as eeeeeee') }) +test('joins configured and contribution-discovered forks without duplication', () => { + const forks = projectForks({ + canonical_repo: 'mobius-os/mobius', + forks: [{ repo: 'owner/mobius', ref: 'fork/main', sha: 'aaaa' }], + }, [{ + id: 'pr', head_repository: 'owner/mobius', last_submit_fork_sha: 'bbbb', + }]) + assert.equal(forks.length, 1) + assert.equal(forks[0].repo, 'owner/mobius') + assert.equal(forks[0].sha, 'aaaa') + assert.deepEqual(forks[0].contributions.map((rec) => rec.id), ['pr']) +}) + +test('install-managed deltas are visible without counting as customization', () => { + const adapted = attachSourceProjects({ + platform: { + ...snapshot.platform, + state: 'adapted', + tree: { + available: true, files: 3, authored_files: 0, managed_files: 3, + insertions: 10, deletions: 2, + }, + origin: { + local_ahead: 1, + local_behind: 0, + local_tree: { + available: true, files: 3, authored_files: 0, managed_files: 3, + insertions: 10, deletions: 2, + }, + }, + }, + apps: [], + }, [])[0] + assert.equal(adapted.different, false) + assert.equal(adapted.adapted, true) + assert.equal(projectStatus(adapted).label, 'Install-managed') + assert.equal(formatSourceDelta(adapted), '3 install-managed') +}) + test('formats authoritative endpoint tree delta', () => { - assert.equal(formatSourceDelta(snapshot.platform), '4 files differ · +12 −3') + assert.equal(formatSourceDelta(snapshot.platform), '4 source files') assert.equal(formatSourceDelta(snapshot.apps[0]), 'Source trees match') }) diff --git a/test/stack.test.mjs b/test/stack.test.mjs new file mode 100644 index 0000000..3571be0 --- /dev/null +++ b/test/stack.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + groupContributionUnits, + preparedContributionUnits, + stackMeta, + stackProgress, +} from '../stack.js' + +function layer(position, status = 'prepared') { + return { + id: `layer-${position}`, + status, + updated_at: `2026-07-15T00:0${position}:00Z`, + plan: { + stack: { + id: 'chat-flow', name: 'Chat flow', position, total: 3, + base_branch: position === 1 ? 'main' : `stack/chat-flow/0${position - 1}`, + parent_record_id: position === 1 ? '' : `layer-${position - 1}`, + }, + }, + } +} + +test('recognizes valid additive stack metadata', () => { + assert.deepEqual(stackMeta(layer(2)), { + id: 'chat-flow', name: 'Chat flow', position: 2, total: 3, + baseBranch: 'stack/chat-flow/01', parentRecordId: 'layer-1', + }) + assert.equal(stackMeta({ plan: {} }), null) + assert.equal(stackMeta({ plan: { stack: { id: 'x', position: 1, total: 1 } } }), null) +}) + +test('groups and orders stack layers without swallowing standalone records', () => { + const standalone = { id: 'solo', status: 'prepared' } + const units = groupContributionUnits([layer(3), standalone, layer(1), layer(2)]) + assert.equal(units.length, 2) + const stack = units.find((unit) => unit.type === 'stack') + assert.deepEqual(stack.records.map((rec) => rec.id), ['layer-1', 'layer-2', 'layer-3']) + assert.equal(units.find((unit) => unit.type === 'record').record, standalone) +}) + +test('a ready child keeps its already-open parent visible in batch review', () => { + const records = [layer(1, 'open'), layer(2), layer(3)] + const units = preparedContributionUnits(records.slice(1), records) + assert.equal(units.length, 1) + assert.deepEqual(units[0].records.map((rec) => rec.status), ['open', 'prepared', 'prepared']) + assert.deepEqual(stackProgress(units[0]), { ready: 2, open: 1, merged: 0, total: 3 }) +}) + +test('batch review never includes an unapproved draft sibling', () => { + const records = [layer(1, 'draft'), layer(2), layer(3)] + const units = preparedContributionUnits(records.slice(1), records) + assert.equal(units.length, 1) + assert.deepEqual(units[0].records.map((rec) => rec.status), ['prepared', 'prepared']) +}) diff --git a/theme.js b/theme.js index 51937e2..2933916 100644 --- a/theme.js +++ b/theme.js @@ -78,7 +78,10 @@ export const CSS = ` /* Sources / Contributions top-level split. Sources gets a wider desktop canvas; Contributions keeps the original 680px reading measure. */ -.co-page.is-sources { width: min(100%, 1040px); } +.co-page.is-sources { + width: min(100%, 1040px); padding-bottom: 12px; + display: flex; flex-direction: column; overflow: hidden; +} .co-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin: 13px 0 16px; padding: 3px; @@ -101,19 +104,16 @@ export const CSS = ` color: var(--accent); font-size: 11px; font-variant-numeric: tabular-nums; } -/* Source map overview. The two rails have distinct jobs: code relationship - (update source → local main → working files) and share relationship - (contribution branches attached below in the detail panel). */ -.co-sources { padding-bottom: 24px; } +/* Repository map — one glance from origin to this Möbius, then outward + to GitHub forks and PR branches. Detail is visual first; file lists sit below. */ +.co-sources { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; } .co-sources-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin: 2px 0 12px; } .co-sources-head h2 { margin: 0; font-size: 17px; line-height: 1.3; } -.co-sources-head p { - max-width: 620px; margin: 4px 0 0; color: var(--muted); - font-size: 12.5px; line-height: 1.5; -} +.co-sources-head p { margin: 4px 0 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; } +.co-sources-head p strong { color: var(--text); font-variant-numeric: tabular-nums; } .co-sources-fresh { flex: 0 0 auto; display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 11.5px; white-space: nowrap; @@ -122,8 +122,7 @@ export const CSS = ` .co-source-error, .co-source-unavailable { border: 1px solid var(--border); border-radius: 11px; - background: var(--surface); color: var(--muted); - font-size: 12.5px; line-height: 1.5; + background: var(--surface); color: var(--muted); font-size: 12px; line-height: 1.5; } .co-source-note { margin-bottom: 10px; padding: 9px 12px; } .co-source-error { @@ -132,24 +131,14 @@ export const CSS = ` } .co-source-error strong { color: var(--text); font-size: 15px; } .co-source-error p { margin: 0; } -.co-source-unavailable { margin-top: 12px; padding: 12px; } +.co-source-unavailable { margin: 12px 0 0; padding: 12px; } -.co-source-summary { - display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; - margin: 10px 0; -} -.co-source-summary > div { - min-width: 0; padding: 11px 12px; border: 1px solid var(--border); - border-radius: 11px; background: var(--surface); -} -.co-source-summary strong { - display: block; font-size: 21px; line-height: 1.15; font-weight: 680; - font-variant-numeric: tabular-nums; +.co-source-toolbar { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + margin: 8px 0 12px; } -.co-source-summary span { display: block; margin-top: 2px; color: var(--muted); font-size: 11.5px; } - .co-source-filters { - display: flex; gap: 6px; padding: 2px 0 12px; overflow-x: auto; + display: flex; gap: 6px; padding: 1px 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-inline: contain; } .co-source-filters::-webkit-scrollbar { display: none; } @@ -163,262 +152,232 @@ export const CSS = ` border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } -.co-source-no-results { - padding: 36px 16px; text-align: center; color: var(--muted); font-size: 13px; -} +.co-adapted-note { flex: 0 0 auto; color: var(--muted); font-size: 10.5px; } +.co-source-no-results { padding: 36px 16px; text-align: center; color: var(--muted); font-size: 13px; } .co-source-layout { - display: grid; grid-template-columns: minmax(310px, 390px) minmax(0, 1fr); - gap: 12px; align-items: start; + flex: 1 1 auto; min-height: 0; + display: grid; grid-template-columns: minmax(270px, 330px) minmax(0, 1fr); + gap: 12px; align-items: stretch; +} +.co-source-list { + min-width: 0; min-height: 0; height: 100%; overflow-y: auto; + display: flex; flex-direction: column; gap: 5px; padding-right: 3px; + scrollbar-width: thin; scrollbar-color: var(--border) transparent; + overscroll-behavior: contain; } -.co-source-list { display: flex; flex-direction: column; gap: 7px; min-width: 0; } .co-source-row-wrap { - min-width: 0; border: 1px solid var(--border); border-radius: 12px; + flex: 0 0 auto; min-width: 0; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); overflow: hidden; } -.co-source-row-wrap.is-selected { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); -} +.co-source-row-wrap.is-selected { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); } .co-source-row { - display: block; width: 100%; padding: 12px; border: 0; - background: transparent; color: var(--text); font: inherit; text-align: left; - cursor: pointer; -} -@media (hover:hover) { - .co-source-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } + display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; + grid-template-rows: auto auto; align-items: center; gap: 2px 9px; + width: 100%; min-height: 58px; + padding: 9px 10px; border: 0; background: transparent; color: var(--text); + font: inherit; text-align: left; cursor: pointer; } -.co-source-row-head { display: flex; align-items: center; gap: 9px; min-width: 0; } +@media (hover:hover) { .co-source-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } } .co-source-glyph { - width: 31px; height: 31px; flex: 0 0 auto; border-radius: 9px; + grid-column: 1; grid-row: 1 / 3; + width: 30px; height: 30px; flex: 0 0 auto; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); - color: var(--accent); font-size: 13px; font-weight: 750; -} -.co-source-glyph.is-platform { - background: color-mix(in srgb, var(--accent) 17%, var(--surface)); + color: var(--accent); font-size: 12px; font-weight: 750; } +.co-source-glyph.is-platform { background: color-mix(in srgb, var(--accent) 17%, var(--surface)); } .co-source-glyph svg { width: 18px; height: 18px; } -.co-source-row-id { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; } -.co-source-row-name { - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13.5px; font-weight: 680; -} -.co-source-row-repo { - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--muted); font-family: var(--mono, var(--font)); font-size: 10.5px; -} +.co-source-row-id { grid-column: 2; grid-row: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.co-source-row-id strong, +.co-source-row-id small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.co-source-row-id strong { font-size: 12.5px; } +.co-source-row-id small { color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; } +.co-source-row-facts { + grid-column: 2; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; + color: var(--muted); font-size: 9px; text-align: left; white-space: nowrap; +} +.co-source-dot { grid-column: 3; grid-row: 1 / 3; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.co-source-dot.tone-accent { background: var(--accent); } +.co-source-dot.tone-ok { background: var(--green); } +.co-source-dot.tone-warn { background: color-mix(in srgb, var(--accent) 70%, var(--text)); } +.co-source-dot.tone-danger { background: var(--danger); } + .co-source-status { flex: 0 0 auto; max-width: 128px; padding: 5px 8px; border-radius: 999px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--surface2, var(--bg)); color: var(--muted); - font-size: 10.5px; line-height: 1; font-weight: 680; + font-size: 10px; line-height: 1; font-weight: 680; } .co-source-status.tone-accent { background: color-mix(in srgb, var(--accent) 13%, transparent); color: var(--accent); } .co-source-status.tone-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); } -.co-source-status.tone-warn { - background: color-mix(in srgb, var(--accent) 11%, transparent); color: var(--text); - border: 1px dashed color-mix(in srgb, var(--accent) 38%, var(--border)); -} +.co-source-status.tone-warn { background: color-mix(in srgb, var(--accent) 11%, transparent); color: var(--text); } .co-source-status.tone-danger { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); } -.co-source-compact { margin: 10px 0 0 40px; } -.co-source-mini-lane { - display: grid; grid-template-columns: minmax(54px, auto) minmax(54px, 1fr) minmax(50px, auto); - align-items: center; gap: 7px; -} -.co-source-mini-ref { - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--muted); font-size: 9.5px; +.co-source-desktop-detail { + position: static; min-width: 0; min-height: 0; height: 100%; overflow-y: auto; + padding-right: 3px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; + overscroll-behavior: contain; } -.co-source-mini-ref.is-local { text-align: right; color: var(--text); } -.co-source-mini-track { position: relative; display: block; height: 12px; } -.co-source-mini-track b { - position: absolute; left: 4px; right: 4px; top: 5px; height: 2px; - background: var(--border); -} -.co-source-mini-track.state-customized b, -.co-source-mini-track.state-diverged b, -.co-source-mini-track.state-working b { background: color-mix(in srgb, var(--accent) 60%, var(--border)); } -.co-source-mini-track.state-incoming b { background: linear-gradient(90deg, var(--accent), var(--border)); } -.co-source-mini-node { - position: absolute; z-index: 1; left: 0; top: 2px; width: 8px; height: 8px; - border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); -} -.co-source-mini-node.is-local { left: auto; right: 0; border-color: var(--accent); } -.co-source-mini-track em { - position: absolute; left: calc(100% + 2px); top: 5px; width: 9px; - border-top: 2px dotted var(--danger); -} -.co-source-compact-meta { - display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; - color: var(--muted); font-size: 10.5px; font-variant-numeric: tabular-nums; -} -.co-source-compact-meta span + span { position: relative; } -.co-source-compact-meta span + span::before { - content: '·'; position: absolute; left: -7px; color: color-mix(in srgb, var(--muted) 65%, transparent); -} -.co-source-compact-meta.is-external { margin-left: 40px; } - -.co-source-desktop-detail { position: sticky; top: 10px; min-width: 0; } -.co-source-mobile-detail { display: none; } +.co-map-back { display: none; } .co-source-detail { - min-width: 0; padding: 15px; border: 1px solid var(--border); + min-width: 0; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } .co-source-detail-head { - display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; - padding-bottom: 13px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding-bottom: 12px; border-bottom: 1px solid var(--border); } -.co-source-detail-title { display: flex; align-items: center; gap: 10px; min-width: 0; } +.co-source-detail-title { display: flex; align-items: center; gap: 9px; min-width: 0; } .co-source-detail-title > div { min-width: 0; } -.co-source-detail-title h3 { - margin: 0; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; -} +.co-source-detail-title h3 { margin: 0; font-size: 15px; line-height: 1.3; overflow-wrap: anywhere; } .co-source-detail-title p { margin: 2px 0 0; color: var(--muted); font-family: var(--mono, var(--font)); - font-size: 10.5px; overflow-wrap: anywhere; + font-size: 10px; overflow-wrap: anywhere; } -.co-rel { - display: grid; grid-template-columns: minmax(0, 1fr) minmax(62px, .65fr) minmax(0, 1fr); - align-items: center; gap: 10px; padding: 16px 2px 13px; +/* Origin → live main is the stable top rail. */ +.co-map-graph { padding: 14px 0; } +.co-map-primary { + display: grid; grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr); + align-items: center; gap: 8px; } -.co-rel-endpoint { min-width: 0; display: flex; flex-direction: column; gap: 2px; } -.co-rel-endpoint.is-local { text-align: right; } -.co-rel-kicker { color: var(--muted); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } -.co-rel-endpoint strong { font-size: 12px; line-height: 1.25; } -.co-rel-endpoint > span:last-child { - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; -} -.co-rel-track { position: relative; height: 20px; } -.co-rel-track b { - position: absolute; left: 7px; right: 7px; top: 9px; height: 2px; background: var(--border); -} -.co-rel-track.state-customized b, -.co-rel-track.state-working b, -.co-rel-track.state-diverged b { background: color-mix(in srgb, var(--accent) 62%, var(--border)); } -.co-rel-track.state-incoming b { background: linear-gradient(90deg, var(--accent), var(--border)); } -.co-rel-track i { - position: absolute; z-index: 1; left: 0; top: 4px; width: 12px; height: 12px; - border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); -} -.co-rel-track i.is-local { left: auto; right: 0; border-color: var(--accent); } -.co-rel-track em { - position: absolute; left: calc(100% + 3px); top: 9px; width: 12px; - border-top: 2px dotted var(--danger); -} -.co-rel-track i.is-working { - left: calc(100% + 14px); right: auto; width: 8px; height: 8px; top: 6px; - border-color: var(--danger); border-style: dashed; -} -.co-rel-metrics { - grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, 1fr); - gap: 6px; margin-top: 3px; -} -.co-rel-metrics span { - min-width: 0; padding: 7px 8px; border-radius: 8px; - background: var(--surface2, var(--bg)); color: var(--muted); - font-size: 10px; text-align: center; white-space: nowrap; +.co-map-node { + position: relative; min-width: 0; display: flex; align-items: flex-start; gap: 8px; + padding: 10px; border: 1px solid var(--border); border-radius: 10px; + background: var(--surface2, var(--bg)); } -.co-rel-metrics strong { color: var(--text); font-variant-numeric: tabular-nums; } - -.co-source-detail-section { padding-top: 13px; margin-top: 1px; border-top: 1px solid var(--border); } -.co-source-detail-section + .co-source-detail-section { margin-top: 13px; } -.co-source-detail-section-head { +.co-map-node.is-local { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); } +.co-map-node.is-fork { border-style: dashed; } +.co-map-node-icon { + width: 22px; height: 22px; flex: 0 0 auto; display: grid; place-items: center; + border-radius: 7px; background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); font-size: 12px; line-height: 1; +} +.co-map-node-copy { min-width: 0; display: flex; flex: 1 1 auto; flex-direction: column; gap: 2px; } +.co-map-node-eyebrow { color: var(--muted); font-size: 8.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; } +.co-map-node-copy strong, +.co-map-node-copy code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.co-map-node-copy strong { font-size: 12px; } +.co-map-node-copy code { color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; } +.co-map-node-copy small { margin-top: 2px; color: var(--muted); font-size: 10px; line-height: 1.35; } +.co-map-node-badges { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } +.co-map-node-badges span { + padding: 3px 5px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); font-size: 8.5px; white-space: nowrap; +} +.co-map-edge { position: relative; height: 22px; color: var(--muted); } +.co-map-edge span { position: absolute; left: 0; right: 7px; top: 10px; border-top: 2px solid var(--border); } +.co-map-edge i { position: absolute; right: 0; top: 1px; font-size: 16px; font-style: normal; } +.co-map-edge.state-customized span, +.co-map-edge.state-working span, +.co-map-edge.state-diverged span { border-color: var(--accent); } +.co-map-edge.state-conflict span { border-color: var(--danger); border-top-style: dashed; } +.co-map-stats { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 7px; +} +.co-map-stats span { + padding: 6px 7px; border-radius: 8px; background: var(--surface2, var(--bg)); + color: var(--muted); font-size: 9px; text-align: center; +} +.co-map-stats b { color: var(--text); font-variant-numeric: tabular-nums; } + +/* Forks hang from the live-main side; PR branches below route back to origin. */ +.co-map-fork-zone { position: relative; margin: 10px 0 0 calc(50% + 27px); padding: 0 0 0 17px; } +.co-map-fork-zone::before { + content: ''; position: absolute; left: 0; top: -10px; bottom: 12px; + border-left: 1px dashed color-mix(in srgb, var(--accent) 42%, var(--border)); +} +.co-map-fork-label { margin: 0 0 5px; color: var(--muted); font-size: 8.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; } +.co-map-fork-row { position: relative; display: grid; gap: 5px; margin-bottom: 8px; } +.co-map-fork-connector { + position: absolute; left: -17px; top: 19px; width: 17px; + border-top: 1px dashed color-mix(in srgb, var(--accent) 42%, var(--border)); +} +.co-map-fork-state { padding-left: 3px; color: var(--muted); font-size: 9px; } +.co-map-fork-state .is-danger { color: var(--danger); } + +.co-map-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; - margin-bottom: 8px; color: var(--muted); font-size: 10.5px; -} -.co-source-detail-section-head h4 { margin: 0; color: var(--text); font-size: 12px; } -.co-source-diff-total { display: inline-flex; gap: 8px; font-family: var(--mono, var(--font)); } -.co-source-detail-empty { margin: 0; color: var(--muted); font-size: 11.5px; line-height: 1.5; } -.co-source-file-list { - border: 1px solid var(--border); border-radius: 9px; overflow: hidden; -} -.co-source-file { - display: flex; align-items: baseline; justify-content: space-between; gap: 10px; - min-height: 35px; padding: 8px 10px; font-family: var(--mono, var(--font)); font-size: 10.5px; -} -.co-source-file + .co-source-file, -.co-source-files-more { border-top: 1px solid var(--border); } -.co-source-file-path { - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.co-source-file-stat { flex: 0 0 auto; display: inline-flex; gap: 7px; color: var(--muted); } -.co-source-file-stat b { color: var(--green); font-style: normal; } -.co-source-file-stat em { color: var(--danger); font-style: normal; } -.co-source-files-more { padding: 8px 10px; color: var(--muted); font-size: 10.5px; text-align: center; } - -.co-source-working-state { color: var(--muted); } -.co-source-working-state.is-dirty { color: var(--danger); } -.co-source-working-counts { display: flex; flex-wrap: wrap; gap: 6px; } -.co-source-working-counts span { - padding: 5px 8px; border-radius: 7px; background: var(--surface2, var(--bg)); - color: var(--muted); font-size: 10.5px; -} -.co-source-working-counts b { color: var(--text); } -.co-source-working-paths { display: flex; flex-direction: column; gap: 6px; margin-top: 9px; } -.co-source-working-paths > div { display: flex; align-items: center; gap: 7px; min-width: 0; } -.co-source-working-paths code { - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--text); font-family: var(--mono, var(--font)); font-size: 10.5px; -} -.co-source-work-chip { - flex: 0 0 auto; padding: 3px 6px; border-radius: 6px; - color: var(--muted); background: var(--surface2, var(--bg)); font-size: 9px; -} -.co-source-work-chip.is-conflict { color: var(--danger); background: color-mix(in srgb, var(--danger) 11%, transparent); } -.co-source-work-chip.is-untracked { color: var(--accent); background: color-mix(in srgb, var(--accent) 11%, transparent); } - -.co-branch-list { display: flex; flex-direction: column; } -.co-branch { display: grid; grid-template-columns: 22px minmax(0, 1fr); min-width: 0; } -.co-branch-stem { position: relative; min-height: 92px; } -.co-branch-stem::before { - content: ''; position: absolute; left: 8px; top: 0; bottom: 0; - border-left: 2px solid var(--border); -} -.co-branch:last-of-type .co-branch-stem::before { bottom: 50%; } -.co-branch-stem i { - position: absolute; z-index: 1; left: 3px; top: 17px; width: 12px; height: 12px; - border: 2px solid var(--accent); border-radius: 50%; background: var(--surface); -} -.co-branch.needs-attention .co-branch-stem i { border-color: var(--danger); } -.co-branch-body { min-width: 0; padding: 10px 0 12px 3px; } -.co-branch-top { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; } -.co-branch-chip { - padding: 4px 7px; border-radius: 999px; font-size: 9.5px; font-weight: 680; - color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); -} -.co-branch-chip.is-danger { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } -.co-branch-number { color: var(--muted); font-family: var(--mono, var(--font)); font-size: 10px; } -.co-branch-title { - display: block; color: var(--text); font-size: 11.5px; font-weight: 650; - line-height: 1.4; text-decoration: none; overflow-wrap: anywhere; -} -a.co-branch-title:hover { text-decoration: underline; } -.co-branch-name { - display: block; margin-top: 4px; color: var(--muted); font-family: var(--mono, var(--font)); - font-size: 9.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.co-branch-relation, -.co-branch-attention { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; } -.co-branch-attention { color: var(--danger); line-height: 1.4; } -.co-source-review-btn { align-self: flex-start; margin: 6px 0 0 25px; } - -.co-source-loading { - display: flex; align-items: center; justify-content: center; gap: 12px; - min-height: 48dvh; color: var(--muted); + margin-bottom: 9px; +} +.co-map-section-head > div { display: flex; align-items: baseline; gap: 7px; } +.co-map-section-head span { color: var(--muted); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.co-map-section-head strong { font-size: 10.5px; } +.co-link-btn { padding: 3px 0; border: 0; background: transparent; color: var(--accent); font: inherit; font-size: 10.5px; cursor: pointer; } + +.co-pr-map, +.co-difference-map { padding-top: 13px; border-top: 1px solid var(--border); } +.co-pr-map + .co-difference-map { margin-top: 13px; } +.co-pr-units { display: flex; flex-direction: column; gap: 7px; } +.co-pr-node { + position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); + padding: 9px 10px 9px 7px; border: 1px solid var(--border); border-radius: 9px; + background: var(--surface2, var(--bg)); } +.co-pr-node-dot { + width: 9px; height: 9px; margin: 5px 0 0 1px; border: 2px solid var(--accent); + border-radius: 50%; background: var(--surface); +} +.co-pr-node-main { min-width: 0; } +.co-pr-node-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.co-pr-status { padding: 3px 6px; border-radius: 999px; color: var(--accent); background: color-mix(in srgb, var(--accent) 11%, transparent); font-size: 8.5px; font-weight: 700; } +.co-pr-status.is-danger { color: var(--danger); background: color-mix(in srgb, var(--danger) 11%, transparent); } +.co-pr-layer, +.co-pr-number { color: var(--muted); font-family: var(--mono, var(--font)); font-size: 8.5px; } +.co-pr-node-main > a, +.co-pr-node-main > strong { display: block; color: var(--text); font-size: 11px; font-weight: 650; line-height: 1.35; text-decoration: none; overflow-wrap: anywhere; } +.co-pr-node-main > a:hover { text-decoration: underline; } +.co-pr-route { display: flex; align-items: center; gap: 5px; min-width: 0; margin-top: 5px; color: var(--accent); } +.co-pr-route code { min-width: 0; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono, var(--font)); font-size: 8.5px; } +.co-pr-node-main > small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; } +.co-pr-node-main > em { display: block; margin-top: 3px; color: var(--danger); font-size: 9px; font-style: normal; } +.co-pr-stack-map { border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); border-radius: 10px; overflow: hidden; } +.co-pr-stack-map > header { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: color-mix(in srgb, var(--accent) 7%, transparent); } +.co-chain-mark { color: var(--accent); font-size: 13px; } +.co-pr-stack-map > header div { display: flex; flex-direction: column; gap: 1px; } +.co-pr-stack-map > header strong { font-size: 10.5px; } +.co-pr-stack-map > header small { color: var(--muted); font-size: 8.5px; } +.co-pr-chain { padding: 7px; } +.co-pr-chain .co-pr-node { border-radius: 7px; } +.co-pr-chain .co-pr-node + .co-pr-node { margin-top: 5px; } +.co-pr-chain .co-pr-node.is-chained::before { + content: ''; position: absolute; left: 12px; top: -7px; height: 11px; + border-left: 2px solid color-mix(in srgb, var(--accent) 42%, var(--border)); +} +.co-pr-chain .co-pr-node:first-child::before { display: none; } + +.co-difference-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.co-difference-group { min-width: 0; padding: 9px; border: 1px solid var(--border); border-radius: 9px; } +.co-difference-group h4 { display: flex; align-items: center; gap: 6px; margin: 0 0 7px; font-size: 10px; } +.co-difference-group h4 b { margin-left: auto; color: var(--muted); font-size: 9px; } +.co-difference-group p { margin: 0; color: var(--muted); font-size: 9.5px; line-height: 1.4; } +.co-diff-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } +.co-diff-dot.is-working { border: 1px dashed var(--danger); background: transparent; } +.co-map-files { min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.co-map-file { min-width: 0; display: flex; align-items: center; gap: 6px; } +.co-map-file code { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-family: var(--mono, var(--font)); font-size: 8.5px; } +.co-map-file > span:last-child { flex: 0 0 auto; display: flex; gap: 4px; color: var(--muted); font-size: 8.5px; } +.co-map-file b { color: var(--green); font-weight: 500; } +.co-map-file em { color: var(--danger); font-style: normal; } +.co-map-files > small { color: var(--muted); font-size: 8.5px; } +.co-map-work { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; background: var(--surface2, var(--bg)); color: var(--muted); font-size: 7.5px; } +.co-map-work.is-conflict { color: var(--danger); } +.co-map-work.is-untracked { color: var(--accent); } +.co-managed-differences { margin-top: 8px; border: 1px dashed var(--border); border-radius: 9px; overflow: hidden; } +.co-managed-differences summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; color: var(--muted); cursor: pointer; list-style: none; } +.co-managed-differences summary::-webkit-details-marker { display: none; } +.co-managed-differences summary span { font-size: 9.5px; } +.co-managed-differences summary i { margin-right: 6px; color: var(--accent); font-style: normal; } +.co-managed-differences summary small { font-size: 8.5px; text-align: right; } +.co-managed-differences[open] .co-map-files { padding: 0 9px 9px; } + +.co-source-loading { display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 48dvh; color: var(--muted); } .co-source-loading > div { display: flex; flex-direction: column; gap: 3px; } .co-source-loading strong { color: var(--text); font-size: 14px; } .co-source-loading span:last-child { font-size: 12px; } @keyframes co-spin { to { transform: rotate(360deg); } } -.ma-spinner { - width: 24px; height: 24px; flex: 0 0 auto; border-radius: 50%; - border: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); - border-top-color: var(--accent); animation: co-spin .8s linear infinite; -} +.ma-spinner { width: 24px; height: 24px; flex: 0 0 auto; border-radius: 50%; border: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); border-top-color: var(--accent); animation: co-spin .8s linear infinite; } @media (prefers-reduced-motion: reduce) { .ma-spinner { animation: none; } } /* Stat tiles: label + value wear text tokens, never status colors — the @@ -537,6 +496,77 @@ a.co-branch-title:hover { text-decoration: underline; } } /* /mobius-ui:Card */ +/* A stack is one approval surface containing individually reviewable layers. + The top rail makes the Git base topology legible before any public action. */ +.co-stack-card { + margin-top: 9px; padding: 14px; border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border)); + background: linear-gradient( + 155deg, + color-mix(in srgb, var(--accent) 7%, var(--surface)), + var(--surface) 42% + ); +} +.co-stack-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; +} +.co-stack-head h3 { margin: 2px 0 0; font-size: 16px; line-height: 1.3; } +.co-stack-head p { margin: 4px 0 0; color: var(--muted); font-size: 11.5px; } +.co-stack-kicker { + color: var(--accent); font-size: 10px; font-weight: 750; + text-transform: uppercase; letter-spacing: .055em; +} +.co-stack-chip { + flex: 0 0 auto; padding: 5px 8px; border-radius: 999px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); font-size: 10px; font-weight: 700; +} +.co-stack-rail { + display: flex; flex-direction: column; gap: 0; margin: 13px 0 2px; + padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); + border-radius: 10px; background: color-mix(in srgb, var(--bg) 74%, transparent); +} +.co-stack-node { + position: relative; display: grid; + grid-template-columns: 13px 40px minmax(0, .72fr) auto minmax(0, 1fr); + align-items: center; gap: 7px; min-height: 28px; + color: var(--muted); font-size: 9.5px; +} +.co-stack-node:not(:last-child)::before { + content: ''; position: absolute; left: 5px; top: 17px; bottom: -11px; + border-left: 2px solid color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.co-stack-node-dot { + position: relative; z-index: 1; width: 11px; height: 11px; border-radius: 50%; + border: 2px solid var(--accent); background: var(--surface); +} +.co-stack-node-layer { color: var(--accent); font-weight: 700; } +.co-stack-node code { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; +} +.co-stack-node code:last-child { color: var(--text); } +.co-stack-layers { margin: 4px 0 0 14px; padding-left: 13px; border-left: 2px solid var(--border); } +.co-stack-layers .co-card { border-radius: 10px; background: var(--surface); } +.co-card.is-stack-layer { box-shadow: none; } +.co-stack-actions { display: flex; gap: 8px; margin-top: 12px; } +.co-stack-actions .co-btn-primary { flex: 1 1 auto; } +.co-stack-confirm { + margin-top: 12px; padding: 12px; border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border)); + background: var(--surface); +} +.co-stack-confirm > strong { font-size: 14px; } +.co-stack-confirm > p { margin: 5px 0 10px; color: var(--muted); font-size: 12px; line-height: 1.5; } +.co-stack-confirm ol { margin: 0; padding: 0; list-style: none; } +.co-stack-confirm li { display: flex; flex-direction: column; gap: 3px; padding: 8px 0; } +.co-stack-confirm li + li { border-top: 1px solid var(--border); } +.co-stack-confirm li span { font-size: 12px; font-weight: 650; } +.co-stack-confirm li code { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; +} + .co-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; @@ -859,15 +889,30 @@ a.co-branch-title:hover { text-decoration: underline; } /* /mobius-ui:Empty */ @media (max-width: 760px) { - .co-page.is-sources { width: min(100%, 680px); } + .co-page.is-sources { + width: min(100%, 680px); padding-bottom: 32px; + display: block; overflow-y: auto; + } + .co-sources { display: block; } + .co-sources.is-detail-open > .co-sources-head, + .co-sources.is-detail-open > .co-source-note, + .co-sources.is-detail-open > .co-source-toolbar { display: none; } .co-sources-head { flex-direction: column; gap: 9px; } .co-sources-fresh { width: 100%; justify-content: space-between; } - .co-source-summary { grid-template-columns: repeat(2, 1fr); } + .co-source-toolbar { align-items: flex-start; flex-direction: column; } .co-source-layout { display: block; } + .co-source-list { height: auto; overflow: visible; padding-right: 0; } .co-source-desktop-detail { display: none; } - .co-source-mobile-detail { display: block; border-top: 1px solid var(--border); } - .co-source-mobile-detail .co-source-detail { border: 0; border-radius: 0; background: transparent; } - .co-source-mobile-detail .co-source-detail-head { display: none; } + .co-source-layout.is-mobile-open .co-source-list { display: none; } + .co-source-layout.is-mobile-open .co-source-desktop-detail { + display: block; height: auto; overflow: visible; padding-right: 0; + } + .co-map-back { + display: inline-flex; align-items: center; gap: 7px; min-height: 44px; + margin: -4px 0 7px; padding: 6px 3px; border: 0; + background: transparent; color: var(--accent); font: inherit; font-size: 12px; + cursor: pointer; + } .co-source-row-wrap.is-selected { box-shadow: none; } } @@ -875,22 +920,29 @@ a.co-branch-title:hover { text-decoration: underline; } .co-page { padding-inline: 12px; } .co-tabs { margin-top: 11px; } .co-tabs button { padding-inline: 8px; font-size: 12.5px; } - .co-source-summary > div { padding: 10px 11px; } - .co-source-summary strong { font-size: 19px; } - .co-source-row { padding: 11px; } - .co-source-compact { margin-left: 0; } - .co-source-mini-lane { margin-left: 40px; } - .co-source-compact-meta { margin-left: 40px; } - .co-source-detail { padding: 13px 11px 15px; } - .co-source-detail-head { align-items: center; } - .co-source-detail-head .co-source-glyph { display: none; } - .co-rel { - grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr); - gap: 7px; + .co-source-row { min-height: 55px; padding: 8px 9px; } + .co-source-detail { padding: 12px 9px 14px; } + .co-map-primary { grid-template-columns: 1fr; gap: 4px; } + .co-map-edge { height: 24px; } + .co-map-edge span { + left: 50%; right: auto; top: 0; width: 0; height: 20px; + border-top: 0; border-left: 2px solid var(--border); } - .co-rel-endpoint strong { font-size: 11px; } - .co-rel-metrics { gap: 4px; } - .co-rel-metrics span { padding-inline: 4px; font-size: 9.5px; } + .co-map-edge i { left: calc(50% - 6px); right: auto; top: 4px; transform: rotate(90deg); } + .co-map-edge.state-customized span, + .co-map-edge.state-working span, + .co-map-edge.state-diverged span { border-left-color: var(--accent); } + .co-map-edge.state-conflict span { border-left-color: var(--danger); border-left-style: dashed; } + .co-map-node { padding: 8px; gap: 6px; } + .co-map-node-icon { display: none; } + .co-map-node-copy strong { font-size: 10.5px; } + .co-map-node-copy small { font-size: 8.5px; } + .co-map-node-badges { display: none; } + .co-map-fork-zone { margin-left: 18px; padding-left: 12px; } + .co-map-fork-connector { left: -12px; width: 12px; } + .co-difference-columns { grid-template-columns: 1fr; } + .co-managed-differences summary { align-items: flex-start; flex-direction: column; } + .co-managed-differences summary small { text-align: left; } .co-attention { flex-direction: column; align-items: stretch; } diff --git a/ui/ContributionCard.jsx b/ui/ContributionCard.jsx index 53f7621..aaa4cd6 100644 --- a/ui/ContributionCard.jsx +++ b/ui/ContributionCard.jsx @@ -384,6 +384,7 @@ export function ContributionCard({ onDismiss, onRestore, loadDiff, + reviewOnly = false, }) { const status = rec.status || 'prepared' const statusLabel = STATUS_LABELS[status] || status @@ -408,8 +409,11 @@ export function ContributionCard({ const hasLink = typeof rec.url === 'string' && rec.url.startsWith('https://github.com/') const reviewable = - status === 'prepared' && typeof onSend === 'function' && - typeof onDismiss === 'function' + status === 'prepared' && ( + reviewOnly || ( + typeof onSend === 'function' && typeof onDismiss === 'function' + ) + ) const hasPlan = reviewable && rec.plan && typeof rec.plan === 'object' const wholeCardTarget = hasLink || hasPlan const reviewLabel = @@ -432,7 +436,7 @@ export function ContributionCard({ return (
@@ -480,12 +484,14 @@ export function ContributionCard({ {reviewable && (!hasPlan || expanded) && (
{hasPlan && } - + {!reviewOnly && ( + + )}
)} {status === 'abandoned' && typeof onRestore === 'function' && ( diff --git a/ui/ContributionStack.jsx b/ui/ContributionStack.jsx new file mode 100644 index 0000000..ab293f5 --- /dev/null +++ b/ui/ContributionStack.jsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react' +import { stackMeta, stackProgress } from '../stack.js' +import { ContributionCard } from './ContributionCard.jsx' + +function branchOf(rec) { + return rec?.plan?.branch || rec?.branch || 'branch unavailable' +} + +function StackRail({ records }) { + return ( +
+ {records.map((rec) => { + const meta = stackMeta(rec) + return ( +
+
+ ) + })} +
+ ) +} + +export function ContributionStack({ + unit, + onSendStack, + onFeedback, + loadDiff, +}) { + const [confirming, setConfirming] = useState(false) + const [sending, setSending] = useState(false) + const [note, setNote] = useState('') + const progress = stackProgress(unit) + const ready = unit.records.filter((rec) => rec.status === 'prepared') + + async function send() { + setSending(true) + setNote('') + try { + const outcome = (await onSendStack(unit.records)) || {} + if (outcome.ok) { + setNote(`${outcome.submitted || ready.length} linked pull requests opened on GitHub.`) + setConfirming(false) + } else { + setNote(outcome.error || 'Could not submit this PR stack.') + } + } finally { + setSending(false) + } + } + + function feedback() { + const rec = ready.find((item) => item.chat_id) || unit.records.find((item) => item.chat_id) + if (!rec || typeof onFeedback !== 'function') { + setNote('This stack does not know which source chat created it.') + return + } + const outcome = onFeedback(rec, { + draft: `Feedback on PR stack ${unit.name} (${unit.id}): `, + }) || {} + if (!outcome.ok) setNote('Open Contribute inside Möbius to return to the source chat.') + } + + return ( +
+
+
+ PR stack · {progress.total} layers +

{unit.name}

+

+ {progress.ready > 0 ? `${progress.ready} ready` : 'All layers sent'} + {progress.open > 0 ? ` · ${progress.open} open` : ''} + {progress.merged > 0 ? ` · ${progress.merged} merged` : ''} +

+
+ Linked +
+ + + +
+ {unit.records.map((rec) => ( + + ))} +
+ + {confirming ? ( +
+ Publish {ready.length} linked {ready.length === 1 ? 'PR' : 'PRs'}? +

+ Contribute will publish these upstream stack branches and open each + pull request against the layer immediately below it. +

+
    + {ready.map((rec) => { + const meta = stackMeta(rec) + return ( +
  1. + {rec.title || branchOf(rec)} + {meta?.baseBranch} → {branchOf(rec)} +
  2. + ) + })} +
+
+ + +
+
+ ) : ( +
+ + +
+ )} + {note &&

{note}

} +
+ ) +} diff --git a/ui/Feed.jsx b/ui/Feed.jsx index e2f3165..399edf8 100644 --- a/ui/Feed.jsx +++ b/ui/Feed.jsx @@ -1,4 +1,6 @@ import { ContributionCard } from './ContributionCard.jsx' +import { ContributionStack } from './ContributionStack.jsx' +import { preparedContributionUnits } from '../stack.js' // The grouped feed. domain.groupRecords partitions the ledger into three // buckets; each renders as a section only when it has rows, so the layout @@ -15,8 +17,18 @@ import { ContributionCard } from './ContributionCard.jsx' // History — merged/closed/commented/abandoned and any unknown // future status. A dropped (abandoned) card gets an Undrop // button (onRestore) to send it back to Ready for review. -export function Feed({ groups, onSend, onFeedback, onDismiss, onRestore, loadDiff }) { +export function Feed({ + groups, + records, + onSend, + onSendStack, + onFeedback, + onDismiss, + onRestore, + loadDiff, +}) { const { ready, open, history } = groups + const readyUnits = preparedContributionUnits(ready, records) return ( <> {ready.length > 0 && ( @@ -26,10 +38,18 @@ export function Feed({ groups, onSend, onFeedback, onDismiss, onRestore, loadDif Review exactly what would go public. Sending a PR publishes it to GitHub directly; feedback returns to the source chat.

- {ready.map((rec) => ( + {readyUnits.map((unit) => unit.type === 'stack' ? ( + + ) : (