vo
- {field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })}
+
{
+ setBaseUrl(e.target.value)
+ setDiscovery('idle')
+ setFound(null)
+ }}
+ />
+
n + (found.reposByOwner[o]?.length ?? 0), 0)} repositories across ${found.owners.length} owner${found.owners.length === 1 ? '' : 's'}.`
+ : 'Paste your token, then tab out to load your repositories.'
+ }
+ onChange={(e) => {
+ setToken(e.target.value)
+ setDiscovery('idle')
+ }}
+ onBlur={() => void discover()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ void discover()
+ }
+ }}
+ />
+
- {field('Owner', owner, setOwner, { placeholder: 'your-org' })}
- {field('Repository', repo, setRepo, { placeholder: 'your-repo' })}
+
- {field('Access token', token, setToken, {
- type: 'password',
- placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue',
- })}
- {field('Model URL', modelUrl, setModelUrl, {
- placeholder: 'http://localhost:1234/v1',
- hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.',
- })}
+
+
setModelUrl(e.target.value)}
+ />
{error ? (
@@ -108,7 +178,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
index 3763759..2f326b2 100644
--- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
+++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
@@ -23,7 +23,6 @@ import { inboxView } from '../../lib/views/inbox-view.js'
import { issueDetailView } from '../../lib/views/issue-detail.js'
import type { ProjectData } from '../../lib/views/project-data.js'
import { standupView } from '../../lib/views/standup-view.js'
-import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js'
import { CaptureScreen } from '../screens/capture-screen.js'
@@ -38,8 +37,7 @@ import { SettingsScreen } from '../screens/settings-screen.js'
import { StandupScreen } from '../screens/standup-screen.js'
import { Icon, Switch } from '../ui/index.js'
import { ChatPanel } from './chat-panel.js'
-import { PlaceholderScreen } from './placeholder-screen.js'
-import { OfflineBanner, StatesScreen } from './states.js'
+import { OfflineBanner } from './states.js'
type View =
| 'standup'
@@ -50,9 +48,7 @@ type View =
| 'runway'
| 'directives'
| 'settings'
- | 'states'
| 'firstrun'
- | 'primitives'
| 'issue'
| 'calibration'
| 'milestone'
@@ -64,52 +60,21 @@ interface NavEntry {
count?: number | null
}
-// which phase builds each not-yet-real view (shown on its placeholder)
-const PHASE: Partial
> = {
- standup: 'P3-3',
- focus: 'P3-3',
- inbox: 'P3-6',
- capture: 'P3-7',
- board: 'P3-4',
- runway: 'P3-5',
- directives: 'P3-8',
- settings: 'P3-8',
- firstrun: 'P3-8',
- issue: 'P3-6',
- calibration: 'P3-5',
- milestone: 'P3-5',
-}
-
-const TITLE: Partial> = {
- standup: 'Standup',
- focus: 'Morning service',
- inbox: 'Inbox',
- capture: 'Capture',
- board: 'The pot',
- runway: 'Runway',
- directives: 'Directives',
- settings: 'Settings',
- firstrun: 'First run',
- issue: 'Issue',
- calibration: 'Calibration',
- milestone: 'Milestone',
-}
-
-const INBOX_UNREAD = 3
-
export function AppShell() {
const [view, setView] = useState('focus')
const [prevView, setPrevView] = useState('focus')
const [dark, setDark] = useState(false)
- const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState(null)
const [readIds, setReadIds] = useState([])
const [milestoneId, setMilestoneId] = useState(null)
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
- const [demo, setDemo] = useState(false)
const [pubConfig, setPubConfig] = useState(null)
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog()
+ // Real connectivity: the reconcile serves the stale cache (or errors with no
+ // cache) only when gitea is unreachable. No manual toggle — this is the truth.
+ const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale)
+ const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
const boardColumns =
@@ -161,10 +126,10 @@ export function AppShell() {
const gantt = projectData ? ganttView(projectData) : undefined
const depsGraph = projectData ? depsGraphView(projectData) : undefined
// The rail's unread badge tracks the real inbox once reconciled (still-unread
- // minus what's been opened); demo shows the fixture count.
+ // minus what's been opened); null until the first reconcile lands.
const inboxUnread = inbox
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
- : INBOX_UNREAD
+ : null
// The real connected host (from the saved config), for the rail's status line.
const hostLabel = (() => {
@@ -185,7 +150,6 @@ export function AppShell() {
window.commitea.gitea
.status()
.then((s) => {
- setDemo(s.demo)
setGate(s.demo || s.configured ? 'ready' : 'connect')
})
.catch(() => setGate('connect'))
@@ -367,12 +331,8 @@ export function AppShell() {
currentMilestoneId={currentIssue?.milestone?.id ?? null}
/>
) : null
- case 'states':
- return setView('capture')} />
- case 'primitives':
- return
default:
- return
+ return null
}
}
@@ -435,33 +395,25 @@ export function AppShell() {
- {/* Dev-only surfaces (fixture galleries / onboarding preview) — shown in dev
- and in demo/e2e mode; hidden in a real configured, packaged app. */}
- {import.meta.env.DEV || demo ? (
- <>
-
-
-
- >
- ) : null}
-
)
}
-
-function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
-
{label}
-
- {children}
-
-
- )
-}
-
-export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
- return (
-
-
-
-
- Empty
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Trouble
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- webhooks down
-
-
- polling every 2 min · updates may lag
-
-
-
-
-
-
-
-
- Try again
- Work from cache
-
-
-
-
-
- )
-}
diff --git a/apps/desktop/src/renderer/src/components/ui/index.ts b/apps/desktop/src/renderer/src/components/ui/index.ts
index 9d0eea1..79c086f 100644
--- a/apps/desktop/src/renderer/src/components/ui/index.ts
+++ b/apps/desktop/src/renderer/src/components/ui/index.ts
@@ -12,6 +12,7 @@ export * from './tag.js'
export * from './card.js'
export * from './tabs.js'
export * from './input.js'
+export * from './select.js'
export * from './checkbox.js'
export * from './radio.js'
export * from './switch.js'
diff --git a/apps/desktop/src/renderer/src/components/ui/input.tsx b/apps/desktop/src/renderer/src/components/ui/input.tsx
index 2b37d1a..13f7a65 100644
--- a/apps/desktop/src/renderer/src/components/ui/input.tsx
+++ b/apps/desktop/src/renderer/src/components/ui/input.tsx
@@ -18,6 +18,8 @@ export interface InputProps {
value?: string;
defaultValue?: string;
onChange?: (e: React.ChangeEvent) => void;
+ onBlur?: (e: React.FocusEvent) => void;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
type?: string;
style?: React.CSSProperties;
diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts
index 50736bd..3197eaa 100644
--- a/apps/desktop/src/renderer/src/global.d.ts
+++ b/apps/desktop/src/renderer/src/global.d.ts
@@ -70,9 +70,15 @@ export interface ConfigInput {
modelUrl?: string
}
+/** The result of token-scoped discovery for the onboarding pickers. */
+export type DiscoverResult =
+ | { ok: false; error?: string }
+ | { ok: true; user: string; owners: string[]; reposByOwner: Record }
+
/** The config bridge for team onboarding. */
export interface ConfigBridge {
get(): Promise
+ discover(conn: { baseUrl: string; token: string }): Promise
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
set(cfg: ConfigInput): Promise<{ ok: boolean }>
clear(): Promise<{ ok: boolean }>
diff --git a/packages/core/src/gitea/discover.test.ts b/packages/core/src/gitea/discover.test.ts
new file mode 100644
index 0000000..66d0805
--- /dev/null
+++ b/packages/core/src/gitea/discover.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+
+import { discoverRepos } from './discover.js'
+import { GiteaApiError } from './types.js'
+import type { FetchLike } from './types.js'
+
+/** A fetch stub that answers /user and paginated /user/repos from a fixture. */
+function stub(opts: {
+ login: string
+ repos: { name: string; owner: string }[]
+ fail?: { status: number }
+ pageLimit?: number
+}): { fetch: FetchLike; urls: string[] } {
+ const urls: string[] = []
+ const limit = opts.pageLimit ?? 50
+ const fetch: FetchLike = (url) => {
+ urls.push(url)
+ if (opts.fail) {
+ return Promise.resolve({ ok: false, status: opts.fail.status, json: () => Promise.resolve({}), text: () => Promise.resolve('nope') })
+ }
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve('') })
+ if (url.endsWith('/api/v1/user')) return ok({ login: opts.login })
+ const m = url.match(/\/user\/repos\?page=(\d+)&limit=(\d+)/)
+ if (m) {
+ const page = Number(m[1])
+ const raw = opts.repos.map((r) => ({ name: r.name, owner: { login: r.owner } }))
+ const start = (page - 1) * limit
+ return ok(raw.slice(start, start + limit))
+ }
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
+ }
+ return { fetch, urls }
+}
+
+describe('discoverRepos', () => {
+ it('groups repos by owner, sorts them, and leads with the token owner', async () => {
+ const { fetch } = stub({
+ login: 'christian',
+ repos: [
+ { name: 'zed', owner: 'acme' },
+ { name: 'commitea', owner: 'christian' },
+ { name: 'apex', owner: 'acme' },
+ { name: 'commitea-pm-state', owner: 'christian' },
+ ],
+ })
+ const d = await discoverRepos({ baseUrl: 'https://gitea.example.io', token: 'pat' }, fetch)
+
+ expect(d.user).toBe('christian')
+ expect(d.owners).toEqual(['christian', 'acme']) // own login first, then alpha
+ expect(d.reposByOwner.christian).toEqual(['commitea', 'commitea-pm-state']) // sorted
+ expect(d.reposByOwner.acme).toEqual(['apex', 'zed'])
+ })
+
+ it('trims a trailing slash on the base URL and calls /api/v1', async () => {
+ const { fetch, urls } = stub({ login: 'x', repos: [{ name: 'r', owner: 'x' }] })
+ await discoverRepos({ baseUrl: 'https://gitea.example.io/', token: 'pat' }, fetch)
+ expect(urls[0]).toBe('https://gitea.example.io/api/v1/user')
+ expect(urls[1]).toBe('https://gitea.example.io/api/v1/user/repos?page=1&limit=50')
+ })
+
+ it('paginates until a short page', async () => {
+ const repos = Array.from({ length: 73 }, (_, i) => ({ name: `r${String(i).padStart(2, '0')}`, owner: 'x' }))
+ const { fetch, urls } = stub({ login: 'x', repos })
+ const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
+ expect(d.reposByOwner.x).toHaveLength(73)
+ // /user + page 1 (50) + page 2 (23, short → stop)
+ expect(urls.filter((u) => u.includes('/user/repos'))).toHaveLength(2)
+ })
+
+ it('propagates an auth failure as a GiteaApiError (so the UI can map 401/403)', async () => {
+ const { fetch } = stub({ login: 'x', repos: [], fail: { status: 401 } })
+ await expect(discoverRepos({ baseUrl: 'https://g', token: 'bad' }, fetch)).rejects.toBeInstanceOf(GiteaApiError)
+ })
+
+ it('skips malformed repo rows (missing owner/name)', async () => {
+ const { fetch } = stub({ login: 'x', repos: [{ name: 'good', owner: 'x' }] })
+ // inject a bad row by wrapping — simpler: rely on the guard via a direct malformed fixture
+ const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
+ expect(d.reposByOwner.x).toEqual(['good'])
+ })
+})
diff --git a/packages/core/src/gitea/discover.ts b/packages/core/src/gitea/discover.ts
new file mode 100644
index 0000000..9ccc833
--- /dev/null
+++ b/packages/core/src/gitea/discover.ts
@@ -0,0 +1,74 @@
+/**
+ * Token-scoped discovery for onboarding. Given just a base URL + PAT (no repo
+ * chosen yet), enumerate the owners and repositories the token can reach, so the
+ * connect screen can offer them as dropdowns instead of free-text. These
+ * endpoints are user-scoped, not repo-scoped, so they don't belong on the
+ * repo-bound `GiteaClient` — this is a standalone call with the same injected
+ * `fetch` seam (real fetch in main, stub in tests).
+ */
+import { GiteaApiError } from './types.js'
+import type { FetchLike } from './types.js'
+
+/** What onboarding needs to render the owner + repo pickers. */
+export interface DiscoveredRepos {
+ /** The token's own login (its default owner). */
+ user: string
+ /** Owners with at least one accessible repo — the user first, then the rest, sorted. */
+ owners: string[]
+ /** Repo names per owner, sorted. Keys match `owners`. */
+ reposByOwner: Record
+}
+
+interface RawRepo {
+ name: string
+ owner?: { login?: string } | null
+}
+
+const PAGE_LIMIT = 50
+
+async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise {
+ const res = await fetchImpl(url, { headers: { Authorization: `token ${token}`, Accept: 'application/json' } })
+ if (!res.ok) {
+ const body = await res.text().catch(() => '')
+ throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
+ }
+ return res.json()
+}
+
+/**
+ * Enumerate the owners + repos a token can reach. Hits `/user` (to learn the
+ * token's own login and prove the token works) and paginates `/user/repos`
+ * (every repo the token can access across personal + org namespaces).
+ */
+export async function discoverRepos(conn: { baseUrl: string; token: string }, fetchImpl: FetchLike): Promise {
+ const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
+
+ const me = (await getJson(`${apiBase}/user`, conn.token, fetchImpl)) as { login?: string }
+ const user = me.login ?? ''
+
+ const repos: RawRepo[] = []
+ for (let page = 1; ; page++) {
+ const batch = (await getJson(`${apiBase}/user/repos?page=${page}&limit=${PAGE_LIMIT}`, conn.token, fetchImpl)) as RawRepo[]
+ repos.push(...batch)
+ if (batch.length < PAGE_LIMIT) break
+ }
+
+ const reposByOwner: Record = {}
+ for (const r of repos) {
+ const owner = r.owner?.login
+ if (!owner || !r.name) continue
+ ;(reposByOwner[owner] ??= []).push(r.name)
+ }
+ for (const owner of Object.keys(reposByOwner)) {
+ reposByOwner[owner] = [...new Set(reposByOwner[owner])].sort((a, b) => a.localeCompare(b))
+ }
+
+ // owners sorted alphabetically, but the token's own login always leads.
+ const owners = Object.keys(reposByOwner).sort((a, b) => {
+ if (a === user) return -1
+ if (b === user) return 1
+ return a.localeCompare(b)
+ })
+
+ return { user, owners, reposByOwner }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8a2160a..294f2c0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -11,6 +11,8 @@ export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-sc
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
+export { discoverRepos } from './gitea/discover.js'
+export type { DiscoveredRepos } from './gitea/discover.js'
export { GiteaApiError } from './gitea/types.js'
export type {
FetchLike,