@@ -15,7 +15,13 @@ import { useEditor } from '@/context/editor-context'
1515import { emit } from '@/lib/events'
1616import { getRecentFolders } from '@/context/local-context'
1717import { 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
2026const 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 ( / ^ h t t p s ? : \/ \/ g i t h u b \. c o m \/ / , '' ) . replace ( / \. g i t $ / , '' ) . 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 */ }
0 commit comments