)}
@@ -95,6 +101,13 @@ export default function ContributeApp({ appId, token }) {
const [fromCache, setFromCache] = useState(false)
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' }
+ })
+ const [sourceSnapshot, setSourceSnapshot] = useState(null)
+ const [sourceLoading, setSourceLoading] = useState(true)
+ const [sourceError, setSourceError] = useState('')
// 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)
@@ -125,6 +138,26 @@ export default function ContributeApp({ appId, token }) {
}
}, [fetchRefreshed])
+ // Local Sources refresh: fetch-free and safe to repeat after an agent edit.
+ // A 404 specifically means this app source arrived before the companion
+ // backend route was restarted into the running server, so say that plainly.
+ const refreshSources = useCallback(async () => {
+ setSourceLoading(true)
+ const result = await fetchSourceStatus(token)
+ if (result.ok) {
+ setSourceSnapshot(result.data)
+ setSourceError('')
+ window.mobius?.signal?.('source_map_viewed', {
+ source_count: 1 + (result.data?.apps?.length || 0),
+ })
+ } else {
+ setSourceError(result.unsupported ? 'restart' : 'unavailable')
+ }
+ setSourceLoading(false)
+ }, [token])
+
+ useEffect(() => { refreshSources() }, [refreshSources])
+
// Re-read connection status after an in-app connect/disconnect, and — when we
// land connected and have a real (non-cached) ledger — re-run the live
// refresh now that GitHub is reachable. Passed to ConnectionCard as onChanged.
@@ -210,6 +243,11 @@ export default function ContributeApp({ appId, token }) {
}
}, [runLiveRefresh])
+ const setView = useCallback((next) => {
+ setViewState(next)
+ try { sessionStorage.setItem('contribute-view-v1', next) } catch { /* optional */ }
+ }, [])
+
// 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
@@ -302,25 +340,64 @@ export default function ContributeApp({ appId, token }) {
const stats = useMemo(() => countStats(records), [records])
const groups = useMemo(() => groupRecords(records), [records])
const isEmpty = records.length === 0
+ const sourceCount = sourceSnapshot
+ ? 1 + (sourceSnapshot.apps?.length || 0)
+ : null
+ const activeCount = stats.open + stats.ready
return (
-
+
-
-
- {/* Hold the feed area blank until the first load resolves so an empty
- ledger doesn't flash the sell-the-loop copy before data arrives. */}
- {loading ? null : isEmpty ?
: (
-
+ setView('sources')}
+ >
+ Sources {sourceCount !== null && {sourceCount} }
+
+ setView('contributions')}
+ >
+ Contributions {activeCount > 0 && {activeCount} }
+
+
+
+ {view === 'sources' ? (
+ setView('contributions')}
/>
+ ) : (
+
+
+
+ {/* Hold the feed area blank until the first load resolves so an empty
+ ledger doesn't flash the sell-the-loop copy before data arrives. */}
+ {loading ? null : isEmpty ? : (
+
+ )}
+
)}
diff --git a/mobius.json b/mobius.json
index 61990d8..a184a40 100644
--- a/mobius.json
+++ b/mobius.json
@@ -38,6 +38,7 @@
"source_files": [
"theme.js",
"domain.js",
+ "source-map.js",
"diff.js",
"api.js",
"storage.js",
@@ -48,6 +49,7 @@
"ui/DiffView.jsx",
"ui/FileDiffList.jsx",
"ui/ContributionCard.jsx",
- "ui/Feed.jsx"
+ "ui/Feed.jsx",
+ "ui/SourceMap.jsx"
]
}
diff --git a/source-map.js b/source-map.js
new file mode 100644
index 0000000..dc5bab2
--- /dev/null
+++ b/source-map.js
@@ -0,0 +1,158 @@
+// Pure Sources-view logic: correlate local repositories with active ledger
+// records, derive attention/filter state, and format relationship labels.
+// React and I/O stay in ui/SourceMap.jsx + api.js so these rules are cheap to
+// exercise under node:test.
+
+const ACTIVE = new Set(['prepared', 'submitting', 'draft', 'open'])
+
+function repoKey(value) {
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
+}
+
+export function activeContribution(rec) {
+ return !!rec && ACTIVE.has(rec.status)
+}
+
+export function recordBranch(rec) {
+ return rec?.branch || rec?.plan?.branch || ''
+}
+
+export function attachSourceProjects(snapshot, records) {
+ const base = []
+ if (snapshot?.platform) base.push(snapshot.platform)
+ if (Array.isArray(snapshot?.apps)) base.push(...snapshot.apps)
+ const active = (records || []).filter(activeContribution)
+ const byRepo = new Map()
+ for (const rec of active) {
+ const key = repoKey(rec.repo || rec.plan?.repo)
+ if (!key) continue
+ const bucket = byRepo.get(key) || []
+ bucket.push(rec)
+ byRepo.set(key, bucket)
+ }
+
+ const seen = new Set()
+ const projects = base.map((project) => {
+ const key = repoKey(project.canonical_repo)
+ if (key) seen.add(key)
+ return decorateProject(project, key ? (byRepo.get(key) || []) : [])
+ })
+
+ // A live contribution can outlast an uninstall or refer to a repository not
+ // installed here. Keep it visible instead of silently dropping it.
+ for (const [repo, contributions] of byRepo.entries()) {
+ if (seen.has(repo)) continue
+ projects.push(decorateProject({
+ key: 'external:' + repo,
+ kind: 'external',
+ name: repo,
+ canonical_repo: repo,
+ available: false,
+ state: 'unavailable',
+ branch: null,
+ base_ref: null,
+ tree: null,
+ working: null,
+ }, contributions))
+ }
+
+ return projects.sort((a, b) => {
+ if (a.kind === 'platform' && b.kind !== 'platform') return -1
+ if (b.kind === 'platform' && a.kind !== 'platform') return 1
+ if (a.attention !== b.attention) return a.attention ? -1 : 1
+ if (a.contributions.length !== b.contributions.length) {
+ return b.contributions.length - a.contributions.length
+ }
+ if (a.different !== b.different) return a.different ? -1 : 1
+ return String(a.name).localeCompare(String(b.name))
+ })
+}
+
+function decorateProject(project, contributions) {
+ const workingFiles = Number(project?.working?.files || 0)
+ const different = Number(project?.tree?.files || 0) > 0
+ const contributionAttention = contributions.some((rec) => rec.needs_attention)
+ const attention = (
+ project?.state === 'conflict' ||
+ project?.state === 'diverged' ||
+ workingFiles > 0 ||
+ contributionAttention
+ )
+ const ready = contributions.filter((rec) => rec.status === 'prepared').length
+ const open = contributions.length - ready
+ return {
+ ...project,
+ contributions,
+ contributionCounts: { ready, open },
+ different,
+ workingFiles,
+ attention,
+ }
+}
+
+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.attention += Number(project.attention)
+ return out
+ }, { sources: 0, different: 0, working: 0, active: 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'
+ return true
+}
+
+export function projectStatus(project) {
+ if (project.kind === 'external') return { label: 'Contribution only', tone: 'quiet' }
+ if (!project.available) return { label: 'Not tracked', tone: 'quiet' }
+ if (project.state === 'conflict') return { label: 'Update conflict', tone: 'danger' }
+ if (project.contributions.some((rec) => rec.needs_attention)) {
+ return { label: 'Needs attention', tone: 'danger' }
+ }
+ if (project.workingFiles > 0) {
+ return { label: project.workingFiles + ' working', tone: 'warn' }
+ }
+ if (project.state === 'diverged') {
+ return { label: 'Both sides changed', tone: 'warn' }
+ }
+ if (project.detached || (project.branch && project.branch !== 'main')) {
+ 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 === 'local_only') return { label: 'Local only', tone: 'quiet' }
+ return { label: 'Aligned', tone: 'ok' }
+}
+
+export function contributionRelationship(rec, project) {
+ const plan = rec?.plan || {}
+ const reviewedHead = plan.head_sha || ''
+ const remoteHead = rec?.last_submit_push_sha || reviewedHead
+ const base = plan.base_sha || rec?.last_submit_upstream_sha || ''
+ const local = project?.head_sha || ''
+ if (remoteHead && local && remoteHead === local) return 'Matches your main'
+ if (base && local && base === local) return 'Based on your main'
+ if (rec?.status === 'prepared') {
+ return base ? 'Private · based on ' + base.slice(0, 7) : 'Private · not public'
+ }
+ if (remoteHead && remoteHead !== reviewedHead) {
+ return 'Published as ' + remoteHead.slice(0, 7)
+ }
+ return base ? 'Based on ' + base.slice(0, 7) : 'Relationship unknown'
+}
+
+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
+}
diff --git a/test/source-map.test.mjs b/test/source-map.test.mjs
new file mode 100644
index 0000000..c2cbe40
--- /dev/null
+++ b/test/source-map.test.mjs
@@ -0,0 +1,74 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import {
+ attachSourceProjects,
+ contributionRelationship,
+ formatSourceDelta,
+ projectMatchesFilter,
+ projectStatus,
+ sourceSummary,
+} from '../source-map.js'
+
+const snapshot = {
+ platform: {
+ key: 'platform', kind: 'platform', name: 'Möbius', available: true,
+ canonical_repo: 'mobius-os/mobius', state: 'diverged', ahead: 23, behind: 2,
+ head_sha: 'aaaaaaaa', base_sha: 'bbbbbbbb',
+ tree: { available: true, files: 4, insertions: 12, deletions: 3 },
+ working: { available: true, files: 0 },
+ },
+ apps: [{
+ key: 'app:80', kind: 'app', name: 'Contribute', available: true,
+ canonical_repo: 'mobius-os/app-contribute', state: 'aligned', ahead: 1, behind: 0,
+ head_sha: 'cccccccc', base_sha: 'dddddddd',
+ tree: { available: true, files: 0, insertions: 0, deletions: 0 },
+ working: { available: true, files: 0 },
+ }],
+}
+
+test('joins only active contribution records to their source project', () => {
+ const records = [
+ { id: 'ready', repo: 'MOBIUS-OS/MOBIUS', status: 'prepared', plan: {} },
+ { id: 'open', repo: 'mobius-os/app-contribute', status: 'open', plan: {} },
+ { id: 'done', repo: 'mobius-os/mobius', status: 'merged', plan: {} },
+ ]
+ const projects = attachSourceProjects(snapshot, records)
+ assert.equal(projects[0].name, 'Möbius')
+ assert.deepEqual(projects[0].contributions.map((r) => r.id), ['ready'])
+ assert.deepEqual(projects[1].contributions.map((r) => r.id), ['open'])
+ assert.equal(sourceSummary(projects).active, 2)
+})
+
+test('tree equality wins over bookkeeping-only ahead history', () => {
+ const projects = attachSourceProjects(snapshot, [])
+ const contribute = projects.find((p) => p.name === 'Contribute')
+ assert.equal(contribute.ahead, 1)
+ assert.equal(contribute.different, false)
+ assert.equal(projectStatus(contribute).label, 'Aligned')
+ assert.equal(projectMatchesFilter(contribute, 'different'), false)
+})
+
+test('active records for an uninstalled repo stay visible', () => {
+ const projects = attachSourceProjects(snapshot, [{
+ id: 'other', repo: 'mobius-os/app-gone', status: 'open', title: 'Still open',
+ }])
+ const external = projects.find((p) => p.kind === 'external')
+ assert.equal(external.canonical_repo, 'mobius-os/app-gone')
+ assert.equal(external.contributions.length, 1)
+ assert.equal(projectStatus(external).label, 'Contribution only')
+})
+
+test('attention and relationship labels preserve real PR head topology', () => {
+ const project = attachSourceProjects(snapshot, [{
+ id: 'pr', repo: 'mobius-os/mobius', status: 'open', needs_attention: true,
+ last_submit_push_sha: 'eeeeeeee',
+ plan: { base_sha: 'bbbbbbbb', head_sha: 'ffffffff' },
+ }])[0]
+ assert.equal(projectStatus(project).label, 'Needs attention')
+ assert.equal(contributionRelationship(project.contributions[0], project), 'Published as eeeeeee')
+})
+
+test('formats authoritative endpoint tree delta', () => {
+ assert.equal(formatSourceDelta(snapshot.platform), '4 files differ · +12 −3')
+ assert.equal(formatSourceDelta(snapshot.apps[0]), 'Source trees match')
+})
diff --git a/theme.js b/theme.js
index 62adf6a..51937e2 100644
--- a/theme.js
+++ b/theme.js
@@ -76,6 +76,351 @@ export const CSS = `
.co-offline-note { display: block; margin: 8px 0 0; font-size: 12px; color: var(--muted); }
+/* 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-tabs {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 3px;
+ margin: 13px 0 16px; padding: 3px;
+ border: 1px solid var(--border); border-radius: 12px;
+ background: var(--surface2, var(--surface));
+}
+.co-tabs button {
+ min-height: 40px; padding: 8px 12px; border: 0; border-radius: 9px;
+ background: transparent; color: var(--muted); font: inherit;
+ font-size: 13px; font-weight: 650; cursor: pointer;
+}
+.co-tabs button.is-active {
+ background: var(--surface); color: var(--text);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent);
+}
+.co-tabs button span {
+ display: inline-flex; align-items: center; justify-content: center;
+ min-width: 20px; height: 20px; margin-left: 5px; padding: 0 6px;
+ border-radius: 999px; background: color-mix(in srgb, var(--accent) 13%, transparent);
+ 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; }
+.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-fresh {
+ flex: 0 0 auto; display: flex; align-items: center; gap: 9px;
+ color: var(--muted); font-size: 11.5px; white-space: nowrap;
+}
+.co-source-note,
+.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;
+}
+.co-source-note { margin-bottom: 10px; padding: 9px 12px; }
+.co-source-error {
+ display: flex; flex-direction: column; align-items: flex-start; gap: 7px;
+ max-width: 520px; margin: 60px auto; padding: 18px;
+}
+.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-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-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;
+ scrollbar-width: none; overscroll-behavior-inline: contain;
+}
+.co-source-filters::-webkit-scrollbar { display: none; }
+.co-source-filter {
+ flex: 0 0 auto; min-height: 34px; padding: 6px 11px;
+ border: 1px solid var(--border); border-radius: 999px;
+ background: transparent; color: var(--muted); font: inherit;
+ font-size: 12px; font-weight: 600; cursor: pointer;
+}
+.co-source-filter.is-active {
+ 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-source-layout {
+ display: grid; grid-template-columns: minmax(310px, 390px) minmax(0, 1fr);
+ gap: 12px; align-items: start;
+}
+.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;
+ 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 {
+ 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); }
+}
+.co-source-row-head { display: flex; align-items: center; gap: 9px; min-width: 0; }
+.co-source-glyph {
+ width: 31px; height: 31px; 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));
+}
+.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-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;
+}
+.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-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-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-source-detail {
+ min-width: 0; padding: 15px; 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);
+}
+.co-source-detail-title { display: flex; align-items: center; gap: 10px; 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 p {
+ margin: 2px 0 0; color: var(--muted); font-family: var(--mono, var(--font));
+ font-size: 10.5px; 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;
+}
+.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-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 {
+ 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);
+}
+.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;
+}
+@media (prefers-reduced-motion: reduce) { .ma-spinner { animation: none; } }
+
/* Stat tiles: label + value wear text tokens, never status colors — the
counts are magnitudes, and the group labels below carry the identity. */
.co-tiles {
@@ -513,7 +858,39 @@ export const CSS = `
.co-empty-text { margin: 0; font-size: 14px; line-height: 1.6; }
/* /mobius-ui:Empty */
+@media (max-width: 760px) {
+ .co-page.is-sources { width: min(100%, 680px); }
+ .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-layout { display: block; }
+ .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-row-wrap.is-selected { box-shadow: none; }
+}
+
@media (max-width: 520px) {
+ .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-rel-endpoint strong { font-size: 11px; }
+ .co-rel-metrics { gap: 4px; }
+ .co-rel-metrics span { padding-inline: 4px; font-size: 9.5px; }
.co-attention {
flex-direction: column; align-items: stretch;
}
diff --git a/ui/SourceMap.jsx b/ui/SourceMap.jsx
new file mode 100644
index 0000000..f176fb5
--- /dev/null
+++ b/ui/SourceMap.jsx
@@ -0,0 +1,429 @@
+import { useEffect, useMemo, useState } from 'react'
+import {
+ attachSourceProjects,
+ contributionRelationship,
+ formatSourceDelta,
+ projectMatchesFilter,
+ projectStatus,
+ recordBranch,
+ sourceSummary,
+} from '../source-map.js'
+
+const FILTERS = [
+ ['all', 'All'],
+ ['attention', 'Attention'],
+ ['different', 'Different'],
+ ['working', 'Working'],
+ ['prs', 'Has PRs'],
+ ['aligned', 'Aligned'],
+]
+
+function shortSha(value) {
+ return value ? String(value).slice(0, 7) : '—'
+}
+
+function countLabel(value, singular, plural = singular + 's') {
+ return value + ' ' + (value === 1 ? singular : plural)
+}
+
+function ProjectGlyph({ project }) {
+ if (project.kind === 'platform') {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+ const letter = String(project.name || '?').trim().charAt(0).toUpperCase() || '?'
+ return
{letter}
+}
+
+function CompactLane({ project }) {
+ const base = project.kind === 'platform'
+ ? 'origin/main'
+ : !project.has_update_source ? 'No update source'
+ : project.version ? 'Installed ' + project.version : (project.base_ref || 'Update source')
+ const incoming = Number(project.behind || 0)
+ const treeFiles = Number(project.tree?.files || 0)
+ const working = Number(project.workingFiles || 0)
+ const counts = project.contributionCounts || { ready: 0, open: 0 }
+ const aria = [
+ project.name,
+ incoming ? countLabel(incoming, 'incoming commit') : 'no incoming commits',
+ !project.has_update_source ? 'no recorded update source'
+ : treeFiles ? countLabel(treeFiles, 'different file') : 'source trees match',
+ working ? countLabel(working, 'working file') : 'no working files',
+ counts.ready ? countLabel(counts.ready, 'ready contribution') : '',
+ counts.open ? countLabel(counts.open, 'open contribution') : '',
+ ].filter(Boolean).join(', ')
+ return (
+
+
+ {base}
+
+
+
+
+ {working > 0 && }
+
+ Your main
+
+
+ {!project.has_update_source
+ ? 'Local Git only'
+ : treeFiles ? treeFiles + (treeFiles === 1 ? ' file differs' : ' files differ') : 'Trees match'}
+ {incoming > 0 && {incoming} incoming }
+ {working > 0 && {working} working }
+ {counts.ready > 0 && {counts.ready} ready }
+ {counts.open > 0 && {counts.open} open }
+
+
+ )
+}
+
+function ProjectRow({ project, selected, onSelect, mobileDetail, onShowContributions }) {
+ const status = projectStatus(project)
+ return (
+
+
onSelect(project.key)}
+ aria-expanded={selected}
+ >
+
+
+
+ {project.name}
+
+ {project.canonical_repo || (project.kind === 'app' ? 'Local app' : 'Local source')}
+
+
+
{status.label}
+
+ {project.kind !== 'external' && project.available && }
+ {project.kind !== 'external' && !project.available && (
+
+ No local Git repository
+
+ )}
+ {project.kind === 'external' && (
+
+ Not installed here
+ {countLabel(project.contributions.length, 'active contribution')}
+
+ )}
+
+ {selected && mobileDetail && (
+
+ )}
+
+ )
+}
+
+function RelationshipRail({ project }) {
+ const working = project.working || {}
+ const baseTitle = project.kind === 'platform' ? 'Last-fetched origin' : 'Installed upstream'
+ const baseSubtitle = project.base_ref || 'No update source'
+ const localTitle = 'Your main'
+ const localSubtitle = project.branch || (project.detached ? 'Detached HEAD' : 'Unknown branch')
+ return (
+
+
+ Update source
+ {baseTitle}
+ {baseSubtitle} · {shortSha(project.base_sha)}
+
+
+
+
+
+ {project.workingFiles > 0 && <> >}
+
+
+ Live source
+ {localTitle}
+ {localSubtitle} · {shortSha(project.head_sha)}
+
+
+ {project.behind ?? '—'} incoming commits
+ {project.ahead ?? '—'} local commits
+ {project.tree?.files ?? '—'} different files
+
+
+ )
+}
+
+function ChangedSource({ project }) {
+ const tree = project.tree
+ if (!tree?.available) return null
+ return (
+
+
+
Changed source
+
+ +{tree.insertions}
+ −{tree.deletions}
+
+
+ {tree.files === 0 ? (
+ The recorded source and your main have the same tree.
+ ) : (
+
+ {(tree.paths || []).map((file) => (
+
+ {file.path}
+
+ {file.binary ? 'binary' : <>+{file.insertions} −{file.deletions} >}
+
+
+ ))}
+ {tree.truncated && (
+
+{tree.files - tree.paths.length} more files
+ )}
+
+ )}
+
+ )
+}
+
+function WorkingSource({ project }) {
+ const working = project.working
+ if (!working?.available) return null
+ const parts = [
+ ['staged', working.staged],
+ ['unstaged', working.unstaged],
+ ['untracked', working.untracked],
+ ['conflicted', working.conflicts],
+ ].filter(([, count]) => count > 0)
+ return (
+
+
+
Working files
+
+ {working.files ? countLabel(working.files, 'file') : 'No working changes'}
+
+
+ {parts.length === 0 ? (
+ Nothing staged, unstaged, or untracked.
+ ) : (
+ <>
+
+ {parts.map(([label, count]) => {count} {label} )}
+
+
+ {(working.paths || []).map((item) => (
+
+ {item.group}
+ {item.path}
+
+ ))}
+ {working.truncated &&
More working paths are not shown. }
+
+ >
+ )}
+
+ )
+}
+
+function ContributionLane({ project, onShowContributions }) {
+ const contributions = project.contributions || []
+ return (
+
+
+
Contribution branches
+ {contributions.length > 0 && {contributions.length} active }
+
+ {contributions.length === 0 ? (
+ No ready or open contributions for this source.
+ ) : (
+
+ {contributions.map((rec) => {
+ const ready = rec.status === 'prepared'
+ const label = ready ? 'Ready' : rec.status === 'submitting' ? 'Submitting' : 'Open'
+ const title = rec.title || rec.summary || recordBranch(rec) || 'Untitled contribution'
+ return (
+
+
+
+
+
+ {rec.needs_attention ? (rec.attention?.title || 'Attention') : label}
+
+ {rec.number && #{rec.number} }
+
+ {rec.url ? (
+
{title}
+ ) :
{title} }
+
{recordBranch(rec) || 'branch unavailable'}
+
{contributionRelationship(rec, project)}
+ {rec.needs_attention && rec.attention?.message && (
+
{rec.attention.message}
+ )}
+
+
+ )
+ })}
+
+ Open Contributions
+
+
+ )}
+
+ )
+}
+
+function ProjectDetail({ project, onShowContributions }) {
+ const status = projectStatus(project)
+ return (
+
+
+
+
+
+
{project.name}
+
{project.canonical_repo || 'This source lives only on this Möbius.'}
+
+
+ {status.label}
+
+ {project.kind !== 'external' && project.available ? (
+ <>
+
+ >
+ ) : project.kind !== 'external' ? (
+
+ This repository could not be inspected. Contributions remain visible below.
+
+ ) : null}
+
+ {project.kind !== 'external' && project.available && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
+
+function LoadingState() {
+ return (
+
+
+
Mapping your sources… Comparing recorded update sources, local mains, and working files.
+
+ )
+}
+
+export function SourceMap({ snapshot, records, conn, loading, error, onRetry, onShowContributions }) {
+ const [filter, setFilter] = useState('all')
+ const projects = useMemo(() => attachSourceProjects(snapshot, records), [snapshot, records])
+ const summary = useMemo(() => sourceSummary(projects), [projects])
+ const filtered = useMemo(
+ () => projects.filter((project) => projectMatchesFilter(project, filter)),
+ [projects, filter],
+ )
+ const [selected, setSelected] = useState('platform')
+ useEffect(() => {
+ if (filtered.length > 0 && !filtered.some((project) => project.key === selected)) {
+ setSelected(filtered[0].key)
+ }
+ }, [filtered, selected])
+ const selectedProject = filtered.find((project) => project.key === selected) || filtered[0]
+
+ if (loading && !snapshot) return
+ if (error && !snapshot) {
+ return (
+
+
{error === 'restart' ? 'Restart to finish Sources' : 'Source map unavailable'}
+
{error === 'restart'
+ ? 'The Sources view is installed, but its new read-only platform service starts after the next Möbius restart.'
+ : 'Contribute could not read local source status. Your contribution feed is unaffected.'}
+
Try again
+
+ )
+ }
+
+ const compared = snapshot?.generated_at
+ ? new Date(snapshot.generated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ : ''
+ return (
+
+
+
+
Source map
+
Recorded update sources, your live mains, working files, and active contribution branches.
+
+
+ {compared && Compared {compared} }
+
+ {loading ? 'Comparing…' : 'Refresh'}
+
+
+
+
+ {conn?.state !== 'connected' && (
+
+ Local comparisons are available; contribution states may be stale until GitHub reconnects.
+
+ )}
+
+
+
{summary.sources} Sources
+
{summary.different} Different
+
{summary.working} Working files
+
{summary.active} Active shares
+
+
+
+ {FILTERS.map(([key, label]) => (
+ setFilter(key)}
+ aria-pressed={filter === key}
+ >
+ {label}
+
+ ))}
+
+
+ {filtered.length === 0 ? (
+ No sources match this filter.
+ ) : (
+
+
+ {filtered.map((project) => (
+
+ ))}
+
+ {selectedProject && (
+
+ )}
+
+ )}
+
+ )
+}