Skip to content

Commit 3d72c08

Browse files
committed
feat: GitHub hub in Settings — Device Flow, PAT, favorites, repos list; clean chat home repo picker
1 parent 64f9cab commit 3d72c08

3 files changed

Lines changed: 327 additions & 237 deletions

File tree

components/chat-home.tsx

Lines changed: 90 additions & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,8 @@ 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 {
19-
fetchRepoByName,
20-
fetchAuthenticatedUser,
21-
startDeviceFlow,
22-
pollDeviceFlow,
23-
type GitHubUser,
24-
} from '@/lib/github-api'
18+
import { fetchRepoByName, fetchAuthenticatedUser, type GitHubUser } from '@/lib/github-api'
19+
import { getFavorites, getRecents, addRecent, type SavedRepo } from '@/lib/github-repos-store'
2520

2621
const STATIC_SUGGESTIONS = [
2722
{
@@ -99,7 +94,7 @@ export const ChatHome = memo(function ChatHome({
9994
const local = useLocal()
10095
const { status } = useGateway()
10196
const { files: openFiles } = useEditor()
102-
const { token: ghToken, setManualToken, authenticated: ghAuthenticated } = useGitHubAuth()
97+
const { token: ghToken, authenticated: ghAuthenticated } = useGitHubAuth()
10398

10499
const repoShort = useMemo(
105100
() => repo?.fullName?.split('/').pop() ?? local.rootPath?.split('/').pop() ?? null,
@@ -115,69 +110,22 @@ export const ChatHome = memo(function ChatHome({
115110
const [repoError, setRepoError] = useState<string | null>(null)
116111
const repoInputRef = useRef<HTMLInputElement>(null)
117112
const [ghUser, setGhUser] = useState<GitHubUser | null>(null)
118-
const [deviceFlow, setDeviceFlow] = useState<{
119-
userCode: string
120-
verificationUri: string
121-
} | null>(null)
122-
const [authLoading, setAuthLoading] = useState(false)
123-
const [showPatInput, setShowPatInput] = useState(false)
124-
const [patInput, setPatInput] = useState('')
125-
const patInputRef = useRef<HTMLInputElement>(null)
126-
const deviceFlowAbort = useRef<AbortController | null>(null)
113+
const [savedFavorites, setSavedFavorites] = useState<SavedRepo[]>([])
114+
const [savedRecents, setSavedRecents] = useState<SavedRepo[]>([])
127115

128116
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
129117

130118
// Fetch GitHub user when token is available
131119
useEffect(() => {
132-
if (!ghToken) {
133-
setGhUser(null)
134-
return
135-
}
120+
if (!ghToken) { setGhUser(null); return }
136121
fetchAuthenticatedUser().then((u) => setGhUser(u))
137122
}, [ghToken])
138123

139-
// GitHub Device Flow sign-in
140-
const startGitHubSignIn = useCallback(async () => {
141-
setAuthLoading(true)
142-
setRepoError(null)
143-
try {
144-
const flow = await startDeviceFlow()
145-
setDeviceFlow({ userCode: flow.user_code, verificationUri: flow.verification_uri })
146-
deviceFlowAbort.current = new AbortController()
147-
const token = await pollDeviceFlow(
148-
flow.device_code,
149-
flow.interval,
150-
deviceFlowAbort.current.signal,
151-
)
152-
setManualToken(token)
153-
setDeviceFlow(null)
154-
} catch (err) {
155-
if ((err as Error).message !== 'Cancelled') {
156-
setRepoError(err instanceof Error ? err.message : 'Sign-in failed')
157-
}
158-
setDeviceFlow(null)
159-
} finally {
160-
setAuthLoading(false)
161-
}
162-
}, [setManualToken])
163-
164-
const cancelGitHubSignIn = useCallback(() => {
165-
deviceFlowAbort.current?.abort()
166-
setDeviceFlow(null)
167-
setAuthLoading(false)
168-
}, [])
169-
170-
const handlePatSubmit = useCallback(() => {
171-
const t = patInput.trim()
172-
if (!t) return
173-
setManualToken(t)
174-
setPatInput('')
175-
setShowPatInput(false)
176-
}, [patInput, setManualToken])
177-
124+
// Load favorites + recents
178125
useEffect(() => {
179-
if (showPatInput) setTimeout(() => patInputRef.current?.focus(), 100)
180-
}, [showPatInput])
126+
setSavedFavorites(getFavorites())
127+
setSavedRecents(getRecents())
128+
}, [ghToken])
181129

182130
const handleRepoConnect = useCallback(async () => {
183131
const val = repoInput
@@ -200,6 +148,13 @@ export const ChatHome = memo(function ChatHome({
200148
fullName: ghRepo.full_name,
201149
}
202150
setRepo(info)
151+
setSavedRecents(addRecent({
152+
fullName: ghRepo.full_name,
153+
name: ghRepo.name,
154+
owner: ghRepo.owner.login,
155+
defaultBranch: ghRepo.default_branch,
156+
addedAt: Date.now(),
157+
}))
203158
setShowRepoInput(false)
204159
setRepoInput('')
205160
} catch (err) {
@@ -209,6 +164,17 @@ export const ChatHome = memo(function ChatHome({
209164
}
210165
}, [repoInput, setRepo])
211166

167+
const selectSavedRepo = useCallback((saved: SavedRepo) => {
168+
const info: RepoInfo = {
169+
owner: saved.owner,
170+
repo: saved.name,
171+
branch: saved.defaultBranch,
172+
fullName: saved.fullName,
173+
}
174+
setRepo(info)
175+
setSavedRecents(addRecent(saved))
176+
}, [setRepo])
177+
212178
useEffect(() => {
213179
if (showRepoInput) {
214180
setTimeout(() => repoInputRef.current?.focus(), 100)
@@ -334,181 +300,111 @@ export const ChatHome = memo(function ChatHome({
334300
{/* Mobile project selector */}
335301
{isMobile && (
336302
<div className="mt-3 flex flex-col items-center gap-2 w-full max-w-[320px]">
337-
{/* GitHub user badge */}
338-
{ghAuthenticated && ghUser && (
339-
<span className="inline-flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
340-
{ghUser.avatar_url && (
341-
<img src={ghUser.avatar_url} alt="" className="w-4 h-4 rounded-full" />
342-
)}
343-
{ghUser.login}
344-
</span>
345-
)}
346-
347-
{/* Sign in with GitHub — when no token */}
348-
{!ghAuthenticated && !deviceFlow && !showPatInput && (
349-
<div className="flex flex-col items-center gap-2">
350-
<button
351-
onClick={startGitHubSignIn}
352-
disabled={authLoading}
353-
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"
354-
>
355-
<Icon icon="lucide:github" width={14} height={14} />
356-
{authLoading ? 'Signing in…' : 'Sign in with GitHub'}
357-
</button>
358-
<button
359-
onClick={() => setShowPatInput(true)}
360-
className="text-[11px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] transition-colors cursor-pointer"
361-
>
362-
or use a personal access token
363-
</button>
364-
</div>
303+
{/* Not signed in — link to Settings */}
304+
{!ghAuthenticated && (
305+
<button
306+
onClick={() => emit('open-settings')}
307+
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"
308+
>
309+
<Icon icon="lucide:github" width={14} height={14} />
310+
Sign in to GitHub via Settings
311+
</button>
365312
)}
366313

367-
{/* PAT input */}
368-
{!ghAuthenticated && showPatInput && !deviceFlow && (
314+
{/* Signed in — favorites + recents picker */}
315+
{ghAuthenticated && !hasWorkspace && !showRepoInput && (
369316
<div className="w-full space-y-2">
370-
<div className="flex items-center gap-1.5">
371-
<div className="flex-1 relative">
372-
<Icon icon="lucide:key-round" width={14} height={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-disabled)]" />
373-
<input
374-
ref={patInputRef}
375-
type="password"
376-
value={patInput}
377-
onChange={(e) => setPatInput(e.target.value)}
378-
onKeyDown={(e) => { if (e.key === 'Enter') handlePatSubmit(); if (e.key === 'Escape') setShowPatInput(false) }}
379-
placeholder="ghp_xxxx..."
380-
autoCapitalize="off"
381-
autoCorrect="off"
382-
spellCheck={false}
383-
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"
384-
/>
317+
{/* Favorites */}
318+
{savedFavorites.length > 0 && (
319+
<div>
320+
<p className="text-[10px] uppercase tracking-wider text-[var(--text-disabled)] font-medium mb-1.5 px-1">Favorites</p>
321+
<div className="space-y-0.5">
322+
{savedFavorites.map(r => (
323+
<button
324+
key={r.fullName}
325+
onClick={() => selectSavedRepo(r)}
326+
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg hover:bg-[color-mix(in_srgb,var(--text-primary)_6%,transparent)] transition-colors cursor-pointer text-left"
327+
>
328+
<Icon icon="lucide:star" width={12} className="text-amber-400 shrink-0" />
329+
<span className="text-[12px] text-[var(--text-primary)] truncate flex-1">{r.fullName}</span>
330+
<Icon icon="lucide:chevron-right" width={12} className="text-[var(--text-disabled)] shrink-0" />
331+
</button>
332+
))}
333+
</div>
385334
</div>
386-
<button
387-
onClick={handlePatSubmit}
388-
disabled={!patInput.trim()}
389-
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)]"
390-
>
391-
Save
392-
</button>
393-
</div>
394-
<p className="text-[10px] text-[var(--text-disabled)] leading-relaxed">
395-
Create a token at{' '}
396-
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-[var(--brand)] underline">
397-
github.com/settings/tokens
398-
</a>
399-
{' '}with <span className="font-mono">repo</span> scope. Stored securely on device.
400-
</p>
401-
<button
402-
onClick={() => { setShowPatInput(false); setPatInput('') }}
403-
className="text-[11px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
404-
>
405-
← back to sign in
406-
</button>
407-
</div>
408-
)}
335+
)}
409336

410-
{/* Device flow — show code */}
411-
{deviceFlow && (
412-
<div className="text-center space-y-2">
413-
<p className="text-[11px] text-[var(--text-disabled)]">
414-
Enter this code at{' '}
415-
<a
416-
href={deviceFlow.verificationUri}
417-
target="_blank"
418-
rel="noopener noreferrer"
419-
className="text-[var(--brand)] underline"
420-
>
421-
github.com/login/device
422-
</a>
423-
</p>
424-
<p className="text-[20px] font-mono font-bold tracking-[0.15em] text-[var(--text-primary)]">
425-
{deviceFlow.userCode}
426-
</p>
337+
{/* Recents */}
338+
{savedRecents.length > 0 && (
339+
<div>
340+
<p className="text-[10px] uppercase tracking-wider text-[var(--text-disabled)] font-medium mb-1.5 px-1">Recent</p>
341+
<div className="space-y-0.5">
342+
{savedRecents.filter(r => !savedFavorites.some(f => f.fullName === r.fullName)).slice(0, 5).map(r => (
343+
<button
344+
key={r.fullName}
345+
onClick={() => selectSavedRepo(r)}
346+
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg hover:bg-[color-mix(in_srgb,var(--text-primary)_6%,transparent)] transition-colors cursor-pointer text-left"
347+
>
348+
<Icon icon="lucide:clock" width={12} className="text-[var(--text-disabled)] shrink-0" />
349+
<span className="text-[12px] text-[var(--text-primary)] truncate flex-1">{r.fullName}</span>
350+
<Icon icon="lucide:chevron-right" width={12} className="text-[var(--text-disabled)] shrink-0" />
351+
</button>
352+
))}
353+
</div>
354+
</div>
355+
)}
356+
357+
{/* Manual input fallback */}
427358
<button
428-
onClick={cancelGitHubSignIn}
429-
className="text-[11px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
359+
onClick={() => setShowRepoInput(true)}
360+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 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"
430361
>
431-
Cancel
362+
<Icon icon="lucide:plus" width={13} />
363+
Open a repository
432364
</button>
433365
</div>
434366
)}
435367

436-
{/* Connect repo — when authenticated */}
437-
{ghAuthenticated && !hasWorkspace && !showRepoInput && (
438-
<button
439-
onClick={() => setShowRepoInput(true)}
440-
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-[color-mix(in_srgb,var(--brand)_28%,var(--border))] bg-[color-mix(in_srgb,var(--brand)_12%,transparent)] text-[12px] font-medium text-[var(--text-primary)] shadow-[0_8px_24px_color-mix(in_srgb,var(--brand)_10%,transparent)] hover:border-[color-mix(in_srgb,var(--brand)_40%,var(--border))] hover:bg-[color-mix(in_srgb,var(--brand)_16%,transparent)] transition-all cursor-pointer"
441-
>
442-
<Icon
443-
icon="lucide:folder-git-2"
444-
width={15}
445-
height={15}
446-
className="text-[var(--brand)]"
447-
/>
448-
Connect a repository
449-
</button>
450-
)}
451-
452368
{/* Repo input */}
453369
{ghAuthenticated && !hasWorkspace && showRepoInput && (
454370
<div className="w-full">
455371
<div className="flex items-center gap-1.5">
456372
<div className="flex-1 relative">
457-
<Icon
458-
icon="lucide:github"
459-
width={14}
460-
height={14}
461-
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-disabled)]"
462-
/>
373+
<Icon icon="lucide:github" width={14} height={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-disabled)]" />
463374
<input
464375
ref={repoInputRef}
465376
type="text"
466377
value={repoInput}
467-
onChange={(e) => {
468-
setRepoInput(e.target.value)
469-
setRepoError(null)
470-
}}
471-
onKeyDown={(e) => {
472-
if (e.key === 'Enter') handleRepoConnect()
473-
if (e.key === 'Escape') setShowRepoInput(false)
474-
}}
378+
onChange={(e) => { setRepoInput(e.target.value); setRepoError(null) }}
379+
onKeyDown={(e) => { if (e.key === 'Enter') handleRepoConnect(); if (e.key === 'Escape') setShowRepoInput(false) }}
475380
placeholder="owner/repo"
476-
autoCapitalize="off"
477-
autoCorrect="off"
478-
spellCheck={false}
381+
autoCapitalize="off" autoCorrect="off" spellCheck={false}
479382
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"
480383
/>
481384
</div>
482385
<button
483386
onClick={handleRepoConnect}
484387
disabled={repoLoading || !repoInput.trim()}
485-
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)]"
388+
className="shrink-0 px-3 py-2 rounded-lg text-[12px] font-medium cursor-pointer disabled:opacity-40 bg-[var(--brand)] text-[var(--brand-contrast,#fff)]"
486389
>
487390
{repoLoading ? '…' : 'Go'}
488391
</button>
489392
</div>
393+
{repoError && <p className="mt-1.5 text-[11px] text-[var(--color-deletions)]">{repoError}</p>}
490394
</div>
491395
)}
492396

493-
{/* Connected repo */}
397+
{/* Connected repo — tap to switch */}
494398
{hasWorkspace && (
495399
<button
496-
onClick={() => {
497-
setRepo(null)
498-
setShowRepoInput(true)
499-
}}
400+
onClick={() => { setRepo(null); setShowRepoInput(false) }}
500401
className="inline-flex items-center gap-1.5 text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors cursor-pointer"
501402
>
502403
<Icon icon="lucide:folder-git-2" width={13} height={13} className="opacity-60" />
503404
{repo?.fullName ?? repoShort}
504405
<Icon icon="lucide:chevron-down" width={12} height={12} className="opacity-40" />
505406
</button>
506407
)}
507-
508-
{/* Error */}
509-
{repoError && (
510-
<p className="text-[11px] text-[var(--color-deletions)]">{repoError}</p>
511-
)}
512408
</div>
513409
)}
514410
</div>

0 commit comments

Comments
 (0)