Skip to content

Commit 8c3942e

Browse files
committed
feat: GitHub device flow sign-in, user badge, auto-load repo tree, fix tab bar spacing
1 parent d31412b commit 8c3942e

4 files changed

Lines changed: 249 additions & 52 deletions

File tree

app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,7 @@ export default function EditorLayout() {
860860
{showMobileBottomTabs && (
861861
<div
862862
className="shrink-0 border-t border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_96%,black)]"
863-
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.25rem)' }}
863+
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
864864
>
865865
<div
866866
className="grid"

components/chat-home.tsx

Lines changed: 146 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import { useEditor } from '@/context/editor-context'
1515
import { emit } from '@/lib/events'
1616
import { getRecentFolders } from '@/context/local-context'
1717
import { getAgentConfig } from '@/lib/agent-session'
18-
import { fetchRepoByName } from '@/lib/github-api'
18+
import {
19+
fetchRepoByName,
20+
fetchAuthenticatedUser,
21+
startDeviceFlow,
22+
pollDeviceFlow,
23+
type GitHubUser,
24+
} from '@/lib/github-api'
1925

2026
const STATIC_SUGGESTIONS = [
2127
{
@@ -93,7 +99,7 @@ export const ChatHome = memo(function ChatHome({
9399
const local = useLocal()
94100
const { status } = useGateway()
95101
const { files: openFiles } = useEditor()
96-
const { token: ghToken } = useGitHubAuth()
102+
const { token: ghToken, setManualToken, authenticated: ghAuthenticated } = useGitHubAuth()
97103

98104
const repoShort = useMemo(
99105
() => repo?.fullName?.split('/').pop() ?? local.rootPath?.split('/').pop() ?? null,
@@ -108,9 +114,46 @@ export const ChatHome = memo(function ChatHome({
108114
const [repoLoading, setRepoLoading] = useState(false)
109115
const [repoError, setRepoError] = useState<string | null>(null)
110116
const repoInputRef = useRef<HTMLInputElement>(null)
117+
const [ghUser, setGhUser] = useState<GitHubUser | null>(null)
118+
const [deviceFlow, setDeviceFlow] = useState<{ userCode: string; verificationUri: string } | null>(null)
119+
const [authLoading, setAuthLoading] = useState(false)
120+
const deviceFlowAbort = useRef<AbortController | null>(null)
111121

112122
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
113123

124+
// Fetch GitHub user when token is available
125+
useEffect(() => {
126+
if (!ghToken) { setGhUser(null); return }
127+
fetchAuthenticatedUser().then(u => setGhUser(u))
128+
}, [ghToken])
129+
130+
// GitHub Device Flow sign-in
131+
const startGitHubSignIn = useCallback(async () => {
132+
setAuthLoading(true)
133+
setRepoError(null)
134+
try {
135+
const flow = await startDeviceFlow()
136+
setDeviceFlow({ userCode: flow.user_code, verificationUri: flow.verification_uri })
137+
deviceFlowAbort.current = new AbortController()
138+
const token = await pollDeviceFlow(flow.device_code, flow.interval, deviceFlowAbort.current.signal)
139+
setManualToken(token)
140+
setDeviceFlow(null)
141+
} catch (err) {
142+
if ((err as Error).message !== 'Cancelled') {
143+
setRepoError(err instanceof Error ? err.message : 'Sign-in failed')
144+
}
145+
setDeviceFlow(null)
146+
} finally {
147+
setAuthLoading(false)
148+
}
149+
}, [setManualToken])
150+
151+
const cancelGitHubSignIn = useCallback(() => {
152+
deviceFlowAbort.current?.abort()
153+
setDeviceFlow(null)
154+
setAuthLoading(false)
155+
}, [])
156+
114157
const handleRepoConnect = useCallback(async () => {
115158
const val = repoInput.trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '')
116159
if (!val || !val.includes('/')) {
@@ -257,62 +300,115 @@ export const ChatHome = memo(function ChatHome({
257300
<Icon icon="lucide:chevron-down" width={14} height={14} className="opacity-50" />
258301
</button>
259302
{/* Mobile project selector */}
260-
{isMobile && !hasWorkspace && !showRepoInput && (
261-
<button
262-
onClick={() => setShowRepoInput(true)}
263-
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-dashed border-[var(--border)] text-[12px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] hover:border-[var(--text-disabled)] transition-all cursor-pointer"
264-
>
265-
<Icon icon="lucide:github" width={14} height={14} />
266-
Connect a repository
267-
</button>
268-
)}
269-
{isMobile && !hasWorkspace && showRepoInput && (
270-
<div className="mt-3 w-full max-w-[320px]">
271-
<div className="flex items-center gap-1.5">
272-
<div className="flex-1 relative">
273-
<Icon icon="lucide:github" width={14} height={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-disabled)]" />
274-
<input
275-
ref={repoInputRef}
276-
type="text"
277-
value={repoInput}
278-
onChange={(e) => { setRepoInput(e.target.value); setRepoError(null) }}
279-
onKeyDown={(e) => { if (e.key === 'Enter') handleRepoConnect(); if (e.key === 'Escape') setShowRepoInput(false) }}
280-
placeholder="owner/repo"
281-
autoCapitalize="off"
282-
autoCorrect="off"
283-
spellCheck={false}
284-
className="w-full pl-8 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_80%,transparent)] text-[13px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] transition-colors"
285-
/>
303+
{isMobile && (
304+
<div className="mt-3 flex flex-col items-center gap-2 w-full max-w-[320px]">
305+
{/* GitHub user badge */}
306+
{ghAuthenticated && ghUser && (
307+
<span className="inline-flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
308+
{ghUser.avatar_url && (
309+
<img src={ghUser.avatar_url} alt="" className="w-4 h-4 rounded-full" />
310+
)}
311+
{ghUser.login}
312+
</span>
313+
)}
314+
315+
{/* Sign in with GitHub — when no token */}
316+
{!ghAuthenticated && !deviceFlow && (
317+
<button
318+
onClick={startGitHubSignIn}
319+
disabled={authLoading}
320+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_80%,transparent)] text-[12px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-disabled)] transition-all cursor-pointer disabled:opacity-50"
321+
>
322+
<Icon icon="lucide:github" width={14} height={14} />
323+
{authLoading ? 'Signing in…' : 'Sign in with GitHub'}
324+
</button>
325+
)}
326+
327+
{/* Device flow — show code */}
328+
{deviceFlow && (
329+
<div className="text-center space-y-2">
330+
<p className="text-[11px] text-[var(--text-disabled)]">
331+
Enter this code at{' '}
332+
<a
333+
href={deviceFlow.verificationUri}
334+
target="_blank"
335+
rel="noopener noreferrer"
336+
className="text-[var(--brand)] underline"
337+
>
338+
github.com/login/device
339+
</a>
340+
</p>
341+
<p className="text-[20px] font-mono font-bold tracking-[0.15em] text-[var(--text-primary)]">
342+
{deviceFlow.userCode}
343+
</p>
344+
<button
345+
onClick={cancelGitHubSignIn}
346+
className="text-[11px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
347+
>
348+
Cancel
349+
</button>
286350
</div>
351+
)}
352+
353+
{/* Connect repo — when authenticated */}
354+
{ghAuthenticated && !hasWorkspace && !showRepoInput && (
287355
<button
288-
onClick={handleRepoConnect}
289-
disabled={repoLoading || !repoInput.trim()}
290-
className="shrink-0 px-3 py-2 rounded-lg text-[12px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default bg-[var(--brand)] text-[var(--brand-contrast,#fff)]"
356+
onClick={() => setShowRepoInput(true)}
357+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-dashed border-[var(--border)] text-[12px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] hover:border-[var(--text-disabled)] transition-all cursor-pointer"
291358
>
292-
{repoLoading ? '…' : 'Go'}
359+
<Icon icon="lucide:folder-git-2" width={14} height={14} />
360+
Connect a repository
293361
</button>
294-
</div>
362+
)}
363+
364+
{/* Repo input */}
365+
{ghAuthenticated && !hasWorkspace && showRepoInput && (
366+
<div className="w-full">
367+
<div className="flex items-center gap-1.5">
368+
<div className="flex-1 relative">
369+
<Icon icon="lucide:github" width={14} height={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-disabled)]" />
370+
<input
371+
ref={repoInputRef}
372+
type="text"
373+
value={repoInput}
374+
onChange={(e) => { setRepoInput(e.target.value); setRepoError(null) }}
375+
onKeyDown={(e) => { if (e.key === 'Enter') handleRepoConnect(); if (e.key === 'Escape') setShowRepoInput(false) }}
376+
placeholder="owner/repo"
377+
autoCapitalize="off"
378+
autoCorrect="off"
379+
spellCheck={false}
380+
className="w-full pl-8 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_80%,transparent)] text-[13px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] transition-colors"
381+
/>
382+
</div>
383+
<button
384+
onClick={handleRepoConnect}
385+
disabled={repoLoading || !repoInput.trim()}
386+
className="shrink-0 px-3 py-2 rounded-lg text-[12px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default bg-[var(--brand)] text-[var(--brand-contrast,#fff)]"
387+
>
388+
{repoLoading ? '…' : 'Go'}
389+
</button>
390+
</div>
391+
</div>
392+
)}
393+
394+
{/* Connected repo */}
395+
{hasWorkspace && (
396+
<button
397+
onClick={() => { setRepo(null); setShowRepoInput(true) }}
398+
className="inline-flex items-center gap-1.5 text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors cursor-pointer"
399+
>
400+
<Icon icon="lucide:folder-git-2" width={13} height={13} className="opacity-60" />
401+
{repo?.fullName ?? repoShort}
402+
<Icon icon="lucide:chevron-down" width={12} height={12} className="opacity-40" />
403+
</button>
404+
)}
405+
406+
{/* Error */}
295407
{repoError && (
296-
<p className="mt-1.5 text-[11px] text-[var(--color-deletions)]">{repoError}</p>
408+
<p className="text-[11px] text-[var(--color-deletions)]">{repoError}</p>
297409
)}
298410
</div>
299411
)}
300-
{isMobile && hasWorkspace && (
301-
<button
302-
onClick={() => { setRepo(null); setShowRepoInput(true) }}
303-
className="mt-2 inline-flex items-center gap-1.5 text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors cursor-pointer"
304-
>
305-
<Icon icon="lucide:github" width={13} height={13} className="opacity-60" />
306-
{repoShort}
307-
<Icon icon="lucide:chevron-down" width={12} height={12} className="opacity-40" />
308-
</button>
309-
)}
310-
{/* Subtle tagline on mobile — only when no repo input shown */}
311-
{isMobile && hasWorkspace && (
312-
<p className="mt-1 text-[11px] text-[var(--text-disabled)]">
313-
{repo?.fullName ?? 'local project'}
314-
</p>
315-
)}
316412
</div>
317413

318414
{/* "Explore more" link — desktop only */}

context/repo-context.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
3+
import { createContext, useContext, useState, useCallback, useEffect, useMemo, type ReactNode } from 'react'
44
import { fetchRepoTreeByName as fetchRepoTree } from '@/lib/github-api'
55

66
export interface RepoInfo {
@@ -48,6 +48,25 @@ export function RepoProvider({ children }: { children: ReactNode }) {
4848
}
4949
}, [repo])
5050

51+
// Auto-load tree when repo changes
52+
useEffect(() => {
53+
if (!repo) { setTree([]); return }
54+
let cancelled = false
55+
;(async () => {
56+
setTreeLoading(true)
57+
setTreeError(null)
58+
try {
59+
const nodes = await fetchRepoTree(repo.fullName, repo.branch)
60+
if (!cancelled) setTree(nodes)
61+
} catch (err) {
62+
if (!cancelled) setTreeError(err instanceof Error ? err.message : 'Failed to load tree')
63+
} finally {
64+
if (!cancelled) setTreeLoading(false)
65+
}
66+
})()
67+
return () => { cancelled = true }
68+
}, [repo]) // eslint-disable-line react-hooks/exhaustive-deps
69+
5170
const value = useMemo<RepoContextValue>(() => ({
5271
repo, setRepo, tree, treeLoading, treeError, loadTree,
5372
}), [repo, setRepo, tree, treeLoading, treeError, loadTree])

lib/github-api.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,88 @@ export interface Repo {
6363
default_branch: string
6464
}
6565

66+
// ─── User ──────────────────────────────────────────────────────
67+
68+
export interface GitHubUser {
69+
login: string
70+
avatar_url: string
71+
name: string | null
72+
}
73+
74+
export async function fetchAuthenticatedUser(): Promise<GitHubUser | null> {
75+
if (!_token) return null
76+
try {
77+
const res = await ghFetch('https://api.github.com/user')
78+
if (!res.ok) return null
79+
return (await res.json()) as GitHubUser
80+
} catch {
81+
return null
82+
}
83+
}
84+
85+
// ─── Device Flow Auth ──────────────────────────────────────────
86+
87+
const GITHUB_CLIENT_ID = 'Ov23liQaoZtc4ji8xCk0'
88+
89+
export interface DeviceCodeResponse {
90+
device_code: string
91+
user_code: string
92+
verification_uri: string
93+
expires_in: number
94+
interval: number
95+
}
96+
97+
export async function startDeviceFlow(): Promise<DeviceCodeResponse> {
98+
const res = await fetch('https://github.com/login/device/code', {
99+
method: 'POST',
100+
headers: {
101+
Accept: 'application/json',
102+
'Content-Type': 'application/json',
103+
},
104+
body: JSON.stringify({
105+
client_id: GITHUB_CLIENT_ID,
106+
scope: 'repo read:user',
107+
}),
108+
})
109+
if (!res.ok) throw new Error(`Device flow failed: ${res.status}`)
110+
return (await res.json()) as DeviceCodeResponse
111+
}
112+
113+
export async function pollDeviceFlow(
114+
deviceCode: string,
115+
interval: number,
116+
signal?: AbortSignal,
117+
): Promise<string> {
118+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
119+
for (let i = 0; i < 60; i++) {
120+
if (signal?.aborted) throw new Error('Cancelled')
121+
await delay(interval * 1000)
122+
const res = await fetch('https://github.com/login/oauth/access_token', {
123+
method: 'POST',
124+
headers: {
125+
Accept: 'application/json',
126+
'Content-Type': 'application/json',
127+
},
128+
body: JSON.stringify({
129+
client_id: GITHUB_CLIENT_ID,
130+
device_code: deviceCode,
131+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
132+
}),
133+
})
134+
const data = (await res.json()) as { access_token?: string; error?: string }
135+
if (data.access_token) return data.access_token
136+
if (data.error === 'authorization_pending') continue
137+
if (data.error === 'slow_down') {
138+
interval += 5
139+
continue
140+
}
141+
if (data.error === 'expired_token') throw new Error('Code expired — try again')
142+
if (data.error === 'access_denied') throw new Error('Access denied')
143+
throw new Error(data.error ?? 'Unknown error')
144+
}
145+
throw new Error('Timed out waiting for authorization')
146+
}
147+
66148
// ─── Repository ────────────────────────────────────────────────
67149

68150
export async function fetchUserRepos(): Promise<Repo[]> {

0 commit comments

Comments
 (0)