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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ a bug or adds a feature, it can offer to share that change upstream so it
ships to every Möbius user — but only with your explicit go-ahead on each
contribution. This app is the dashboard for that loop:

- **Sources** — a fetch-free map of the platform and every installed app.
Each project keeps two relationships separate: the recorded update source
versus your live `main`, and the ready/open contribution branches attached
to that project. Tree-delta counts avoid treating installation bookkeeping
commits as source changes; staged, unstaged, untracked, and conflicted files
are shown independently. Filters surface attention, different trees, working
files, active PRs, or aligned projects.
- **Stat tiles** — Merged / Open / Ready at a glance.
- **Connection card** — connect GitHub right here, in the app. Two paths to
the same server-side credential: the GitHub **device flow** (shown when the
Expand Down Expand Up @@ -63,7 +70,8 @@ on install and update, so the skill always matches the app version.
## Requirements

- A Möbius platform version that provides the `/api/github/*` surface
(`/api/github/status` and the read-only `/api/github/graphql`). On an older
(`/api/github/status`, fetch-free `/api/github/source-status`, and the
read-only `/api/github/graphql`). On an older
platform the connection card says so and points you to update. If the status
request 404s, ask your agent to update the platform.
- This app declares `permissions.github_access: true` in its manifest. That
Expand Down
23 changes: 23 additions & 0 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ export async function fetchGithubStatus(token) {
}
}

// Fetch-free local Git metadata for the Sources view. The endpoint is narrow:
// refs, ancestry/diff magnitudes, working-tree counts, and capped path names —
// never source contents or absolute paths. A failure leaves the contribution
// feed usable and lets the Sources view offer an explicit retry.
export async function fetchSourceStatus(token) {
try {
const r = await fetch('/api/github/source-status', {
headers: authHeaders(token),
})
if (!r.ok) {
return {
ok: false,
unsupported: r.status === 404,
status: r.status,
}
}
const body = await r.json()
return { ok: true, data: body }
} catch {
return { ok: false, offline: true, status: 0 }
}
}

// POSTs one read-only GraphQL document; returns response.data or null.
// Callers leave records stale on null — the refresh is best-effort polish,
// and GitHub returns null nodes (not errors) for anything inaccessible.
Expand Down
107 changes: 92 additions & 15 deletions index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ import {
groupRecords,
} from './domain.js'
import { abandonPrepared, cacheFeed, loadFullDiff, loadLedger, restoreAbandoned } from './storage.js'
import { fetchGithubStatus, fetchLiveStates, submitContribution } from './api.js'
import {
fetchGithubStatus,
fetchLiveStates,
fetchSourceStatus,
submitContribution,
} from './api.js'
import { StatTiles } from './ui/StatTiles.jsx'
import { ConnectionCard } from './ui/ConnectionCard.jsx'
import { Feed } from './ui/Feed.jsx'
import { SourceMap } from './ui/SourceMap.jsx'

// The one icon that isn't chrome: the empty-state mark. A branch merging up
// into a trunk — the same motif as the app icon, so the two read as kin.
Expand Down Expand Up @@ -64,7 +70,7 @@ function Header({ appId, fromCache }) {
</span>
<div>
<h1 className="co-title">Contribute</h1>
<span className="co-subtitle">What your agent has shared upstream</span>
<span className="co-subtitle">Your local work and its path upstream</span>
{fromCache && (
<span className="co-offline-note">Offline — showing your last synced feed.</span>
)}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div className="co-root">
<style>{CSS}</style>
<div className="co-page">
<div className={'co-page' + (view === 'sources' ? ' is-sources' : '')}>
<Header appId={appId} fromCache={fromCache} />
<StatTiles stats={stats} />
<ConnectionCard conn={conn} token={token} onChanged={refreshConnection} />
{/* 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 ? <EmptyState /> : (
<Feed
groups={groups}
onSend={onSend}
onFeedback={onFeedback}
onDismiss={onDismiss}
onRestore={onRestore}
loadDiff={loadFullDiff}
<nav className="co-tabs" role="tablist" aria-label="Contribute views">
<button
type="button"
role="tab"
aria-selected={view === 'sources'}
className={view === 'sources' ? 'is-active' : ''}
onClick={() => setView('sources')}
>
Sources {sourceCount !== null && <span>{sourceCount}</span>}
</button>
<button
type="button"
role="tab"
aria-selected={view === 'contributions'}
className={view === 'contributions' ? 'is-active' : ''}
onClick={() => setView('contributions')}
>
Contributions {activeCount > 0 && <span>{activeCount}</span>}
</button>
</nav>

{view === 'sources' ? (
<SourceMap
snapshot={sourceSnapshot}
records={records}
conn={conn}
loading={sourceLoading}
error={sourceError}
onRetry={refreshSources}
onShowContributions={() => setView('contributions')}
/>
) : (
<div className="co-contributions-view">
<StatTiles stats={stats} />
<ConnectionCard conn={conn} token={token} onChanged={refreshConnection} />
{/* 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 ? <EmptyState /> : (
<Feed
groups={groups}
onSend={onSend}
onFeedback={onFeedback}
onDismiss={onDismiss}
onRestore={onRestore}
loadDiff={loadFullDiff}
/>
)}
</div>
)}
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion mobius.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"source_files": [
"theme.js",
"domain.js",
"source-map.js",
"diff.js",
"api.js",
"storage.js",
Expand All @@ -48,6 +49,7 @@
"ui/DiffView.jsx",
"ui/FileDiffList.jsx",
"ui/ContributionCard.jsx",
"ui/Feed.jsx"
"ui/Feed.jsx",
"ui/SourceMap.jsx"
]
}
158 changes: 158 additions & 0 deletions source-map.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading