evOo-local/apps/web/src/components/GithubRepoPicker.tsx
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

306 lines
11 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import { Button, Input, Spinner } from "@rakazo/ui-web";
import { Check, ExternalLink, GitBranch, Lock, RefreshCw, Search, X } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { rpc } from "../lib/rpc";
export interface GithubRepoItem {
name: string;
fullName: string;
url: string;
description?: string | null;
isPrivate: boolean;
updatedAt?: string;
}
export interface GithubRepoPickerProps {
value: string;
onChange: (url: string, repo?: { name: string; fullName: string }) => void;
onSuggestProjectName?: (name: string) => void;
onSuggestWorkspacePath?: (path: string) => void;
disabled?: boolean;
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.2.8.8 1.2 1.9 1.2 3.1 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1.1.9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
</svg>
);
}
export function GithubRepoPicker({
value,
onChange,
onSuggestProjectName,
onSuggestWorkspacePath,
disabled = false,
}: GithubRepoPickerProps) {
const { t } = useLingui();
const searchId = useId();
const manualId = useId();
const [connected, setConnected] = useState<boolean | null>(null);
const [username, setUsername] = useState<string | undefined>(undefined);
const [repos, setRepos] = useState<GithubRepoItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showManualInput, setShowManualInput] = useState(false);
const fetchRepos = useCallback(async () => {
setLoading(true);
try {
const res = await rpc.spaces.listGithubRepos({ limit: 100 });
setConnected(res.connected);
setUsername(res.username);
setRepos(res.repos);
if (!res.connected && !value) {
setShowManualInput(true);
}
} catch {
setConnected(false);
setShowManualInput(true);
} finally {
setLoading(false);
}
}, [value]);
useEffect(() => {
void fetchRepos();
}, [fetchRepos]);
const filteredRepos = useMemo(() => {
if (!searchQuery.trim()) return repos;
const q = searchQuery.trim().toLowerCase();
return repos.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}, [repos, searchQuery]);
const handleSelectRepo = (repo: GithubRepoItem) => {
onChange(repo.url, { name: repo.name, fullName: repo.fullName });
if (onSuggestProjectName) {
onSuggestProjectName(repo.name);
}
if (onSuggestWorkspacePath) {
onSuggestWorkspacePath(`~/Projects/${repo.name}`);
}
};
const selectedRepo = useMemo(() => {
if (!value) return null;
return (
repos.find(
(r) =>
r.url.toLowerCase() === value.toLowerCase() ||
r.url.replace(/\.git$/, "").toLowerCase() === value.replace(/\.git$/, "").toLowerCase(),
) ?? null
);
}, [repos, value]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-ui-body-sm">
<div className="flex items-center gap-1.5 text-foreground/80 font-medium">
<GithubMark className="size-4 text-foreground shrink-0" />
<span>
<Trans>GitHub repository</Trans>
</span>
<span className="text-ui-body-xs text-muted-foreground font-normal">
<Trans>(optional)</Trans>
</span>
</div>
{connected ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-2 py-0.5 text-ui-body-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-500" />
{username ? `@${username}` : t`Connected`}
</span>
<button
type="button"
onClick={() => setShowManualInput(!showManualInput)}
className="text-ui-body-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showManualInput ? t`Show list` : t`Manual URL`}
</button>
</div>
) : connected === false ? (
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Refresh GitHub`}
onClick={() => void fetchRepos()}
disabled={loading}
className="size-6 text-muted-foreground hover:text-foreground"
>
<RefreshCw size={13} className={loading ? "animate-spin" : undefined} />
</Button>
</div>
) : null}
</div>
{showManualInput || connected === false ? (
<div className="flex gap-2">
<Input
id={manualId}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
placeholder="https://github.com/organization/repository"
spellCheck={false}
className="text-ui-body-sm"
/>
{value ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Clear repository`}
onClick={() => onChange("")}
className="shrink-0 size-9 text-muted-foreground hover:text-foreground"
>
<X size={16} strokeWidth={1.8} />
</Button>
) : null}
</div>
) : null}
{connected && !showManualInput ? (
<div className="space-y-1.5">
{/* Active selection banner if a repo is currently selected */}
{selectedRepo ? (
<div className="flex items-center justify-between px-3 py-2 rounded-lg border border-primary/40 bg-primary/5 text-ui-body-sm">
<div className="flex items-center gap-2 min-w-0 flex-1">
{selectedRepo.isPrivate ? (
<Lock size={14} className="text-muted-foreground shrink-0" />
) : (
<GitBranch size={14} className="text-muted-foreground shrink-0" />
)}
<span className="font-medium text-foreground truncate">
{selectedRepo.fullName}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Clear selected repository`}
onClick={() => onChange("")}
className="size-6 text-muted-foreground hover:text-foreground"
>
<X size={14} strokeWidth={2} />
</Button>
</div>
</div>
) : null}
{/* Mini-search input */}
<div className="relative">
<Search
size={14}
strokeWidth={2}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={searchId}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search repositories...`}
disabled={disabled || loading}
className="pl-8 h-8 text-ui-body-sm"
/>
{loading ? (
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
) : null}
</div>
{/* Repositories list */}
<div className="max-h-44 overflow-y-auto rounded-lg border border-border bg-card divide-y divide-border/30">
{loading && repos.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Spinner className="size-4 text-muted-foreground" />
</div>
) : filteredRepos.length === 0 ? (
<div className="py-6 text-center text-ui-body-sm text-muted-foreground">
{searchQuery ? (
<Trans>No matching repositories</Trans>
) : (
<Trans>No repositories found</Trans>
)}
</div>
) : (
filteredRepos.map((repo) => {
const isSelected =
value.toLowerCase() === repo.url.toLowerCase() ||
value.replace(/\.git$/, "").toLowerCase() ===
repo.url.replace(/\.git$/, "").toLowerCase();
return (
<button
key={repo.fullName}
type="button"
disabled={disabled}
onClick={() => handleSelectRepo(repo)}
className={`w-full flex items-start gap-2.5 px-3 py-2 text-left transition-colors ${
isSelected
? "bg-primary text-primary-foreground font-medium"
: "hover:bg-muted text-foreground"
}`}
>
<div className="pt-0.5 shrink-0">
{repo.isPrivate ? (
<Lock
size={14}
strokeWidth={1.8}
className={
isSelected ? "text-primary-foreground" : "text-muted-foreground"
}
/>
) : (
<GitBranch
size={14}
strokeWidth={1.8}
className={
isSelected ? "text-primary-foreground" : "text-muted-foreground"
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-ui-body-sm font-medium">
{repo.fullName}
</span>
{isSelected ? (
<Check size={14} strokeWidth={2.5} className="shrink-0" />
) : null}
</div>
{repo.description ? (
<p
className={`truncate text-ui-body-xs mt-0.5 ${
isSelected ? "text-primary-foreground/80" : "text-muted-foreground"
}`}
>
{repo.description}
</p>
) : null}
</div>
</button>
);
})
)}
</div>
</div>
) : null}
</div>
);
}