feat: massive chat reliability + UX pass (web/desktop/mobile/vscode) (#593)
## Added Features - Add VS Code save-as-image flow for assistant messages via webview bridge + native save dialog. - Add hourly desktop update checks after startup. - Add new tool output display mode: `Changes` (auto-expand edit/write/patch only; keep activity expanded; mode guidance text). - Add GitHub PR attachment flow in chat input with PR picker + attached PR chip/details. - Add mobile overlay presentation for GitHub Issue and PR pickers (shared with desktop picker content). ## Fixes - Save-gate project icon updates until explicit Save; allow icon removal with same save-gated behavior. - Restore clickable chat action buttons in sticky header mode (desktop + Firefox hit-target issue). - Clamp sticky user messages to bounded chat height and allow internal scrolling. - Prevent drawer context crash during iPad/tablet orientation switching. - Improve text-selection action menu placement on narrow screens. - Move assistant message time into clock tooltip; keep duration display clean. - Hide `Link GitHub Issue` row in VS Code chat input area (GitHub flow is not yet ready there). - Remove laggy close animation in text-selection popover; keep open motion/positioning behavior. - Fetch branches when picker opens and cache empty; show loading state instead of false “No branches found”. - Fix share-image export metadata rendering (theme background resolution, timestamp rendering, footer alignment). - Scope MCP services status/toggles to active directory to avoid cross-project leakage. - Improve long user-message clamp behavior (40% cap variant, hidden scrollbar, scroll shadows, expansion detection). - Fix desktop `Check for Updates` menu handler; prevent duplicate checks; show clear success/error toasts. - Stabilize long user-message scrolling behavior (follow-up hardening). - Avoid premature web update failure on slower servers. - Restore user message image previews + fullscreen gallery navigation payload. - Repair desktop chat drag-and-drop image attachments when native drop coords are missing. - Move GitHub issue linking entry into Add attachment menu. - Align header context usage percentage visuals with context panel. - Align `@` file search with active project in all runtimes. - Route `@` file discovery through OpenCode SDK `find.files`; remove legacy `/api/fs/search` reliance. - Make chat `@` mention behavior consistent with files-style behavior. - Keep status-row todos in stable order after status changes; add compact status icons; replace noisy priority labels. ## Refactors / UX Consistency - Simplify chat attachment model and remove project file picker path. - Keep composer focused on `@` mention file flow. - Use direct `Attach files` action in VS Code instead of attachment dropdown path. - Unify issue/PR picker behavior between desktop and mobile overlays.
This commit is contained in:
committed by
GitHub
parent
ca18b8be0f
commit
79143bff4c
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
RiCheckboxBlankLine,
|
||||
@@ -79,6 +80,7 @@ export function GitHubIssuePickerDialog({
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
@@ -512,32 +514,24 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
||||
}
|
||||
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiGithubLine className="h-5 w-5" />
|
||||
{mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'select'
|
||||
? 'Select an issue to link to this session.'
|
||||
: 'Seeds a new session with hidden issue context (title/body/labels/comments).'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
const title = mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue';
|
||||
const description = mode === 'select'
|
||||
? 'Select an issue to link to this session.'
|
||||
: 'Seeds a new session with hidden issue context (title/body/labels/comments).';
|
||||
|
||||
<div className="relative mt-2">
|
||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or #123, or paste issue URL"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative mt-2">
|
||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or #123, or paste issue URL"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto mt-2">
|
||||
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
|
||||
{!projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
||||
) : null}
|
||||
@@ -649,9 +643,9 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode !== 'select' && (
|
||||
{mode !== 'select' && (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
@@ -703,7 +697,45 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={() => onOpenChange(false)}
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-small text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiGithubLine className="h-5 w-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
RiGithubLine,
|
||||
RiLoader4Line,
|
||||
RiSearchLine,
|
||||
RiExternalLinkLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
|
||||
|
||||
const parsePrNumber = (value: string): number | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i);
|
||||
if (urlMatch) {
|
||||
const parsed = Number(urlMatch[1]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
const hashMatch = trimmed.match(/^#?(\d+)$/);
|
||||
if (hashMatch) {
|
||||
const parsed = Number(hashMatch[1]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
|
||||
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
const PR_REVIEW_INSTRUCTIONS = `Before reporting issues:
|
||||
- First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep.
|
||||
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||||
- No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing.
|
||||
|
||||
Output rules:
|
||||
- Start with a 1-2 sentence summary.
|
||||
- Provide a single concise PR review comment.
|
||||
- No emojis. No code snippets. No fenced blocks.
|
||||
- Short inline code identifiers allowed, but no snippets or fenced blocks.
|
||||
- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why.
|
||||
- Keep the entire comment under ~300 words.
|
||||
|
||||
Report:
|
||||
- Must-fix issues (blocking)-brief why and a one-line action each.
|
||||
- Nice-to-have improvements (optional)-brief why and a one-line action each.
|
||||
|
||||
Quality & safety (general):
|
||||
- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks.
|
||||
- Call out missing tests/verification steps and suggest the minimal validation needed.
|
||||
- Note readability/maintainability issues when they materially affect future changes.
|
||||
|
||||
Applicability (only if relevant):
|
||||
- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why.
|
||||
|
||||
Architecture:
|
||||
- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility).
|
||||
|
||||
Precedence:
|
||||
- If local precedent conflicts with best practices, state it and suggest a follow-up task.
|
||||
|
||||
Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan.
|
||||
|
||||
Format exactly:
|
||||
Must-fix:
|
||||
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
|
||||
Nice-to-have:
|
||||
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
|
||||
If no issues, write:
|
||||
Must-fix:
|
||||
- None
|
||||
Nice-to-have:
|
||||
- None`;
|
||||
|
||||
export function GitHubPrPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect?: (pr: {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
}) => void;
|
||||
}) {
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [includeDiff, setIncludeDiff] = React.useState(false);
|
||||
const [result, setResult] = React.useState<GitHubPullRequestsListResult | null>(null);
|
||||
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [loadingPrNumber, setLoadingPrNumber] = React.useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
setError('No active project');
|
||||
return;
|
||||
}
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
setResult({ connected: false });
|
||||
setPrs([]);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!github?.prsList) {
|
||||
setResult(null);
|
||||
setError('GitHub runtime API unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await github.prsList(projectDirectory, { page: 1 });
|
||||
setResult(next);
|
||||
setPrs(next.prs ?? []);
|
||||
setPage(next.page ?? 1);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
if (next.connected === false) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory) return;
|
||||
if (!github?.prsList) return;
|
||||
if (isLoadingMore || isLoading) return;
|
||||
if (!hasMore) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const next = await github.prsList(projectDirectory, { page: nextPage });
|
||||
setResult(next);
|
||||
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
|
||||
setPage(next.page ?? nextPage);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to load more pull requests', { description: message });
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
setIncludeDiff(false);
|
||||
setLoadingPrNumber(null);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setPrs([]);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
setResult({ connected: false });
|
||||
setPrs([]);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
setError(null);
|
||||
}
|
||||
}, [githubAuthChecked, githubAuthStatus, open]);
|
||||
|
||||
const connected = githubAuthChecked ? result?.connected !== false : true;
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return prs;
|
||||
return prs.filter((pr) => {
|
||||
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||
return pr.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [prs, query]);
|
||||
|
||||
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
|
||||
|
||||
const attachPr = React.useCallback(async (prNumber: number) => {
|
||||
if (!projectDirectory) {
|
||||
toast.error('No active project');
|
||||
return;
|
||||
}
|
||||
if (!github?.prContext) {
|
||||
toast.error('GitHub runtime API unavailable');
|
||||
return;
|
||||
}
|
||||
if (loadingPrNumber) return;
|
||||
|
||||
setLoadingPrNumber(prNumber);
|
||||
try {
|
||||
const context = await github.prContext(projectDirectory, prNumber, {
|
||||
includeDiff,
|
||||
includeCheckDetails: false,
|
||||
});
|
||||
|
||||
if (context.connected === false) {
|
||||
toast.error('GitHub not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.pr) {
|
||||
toast.error('Pull request not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.repo) {
|
||||
toast.error('Repo not resolvable', {
|
||||
description: 'origin remote must be a GitHub URL',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect({
|
||||
number: context.pr.number,
|
||||
title: context.pr.title,
|
||||
url: context.pr.url,
|
||||
head: context.pr.head,
|
||||
base: context.pr.base,
|
||||
includeDiff,
|
||||
instructionsText: PR_REVIEW_INSTRUCTIONS,
|
||||
contextText: buildPullRequestContextText(context),
|
||||
author: context.pr.author
|
||||
? {
|
||||
login: context.pr.author.login,
|
||||
avatarUrl: context.pr.author.avatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to load pull request details', { description: message });
|
||||
} finally {
|
||||
setLoadingPrNumber(null);
|
||||
}
|
||||
}, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory]);
|
||||
|
||||
const title = 'Link GitHub Pull Request';
|
||||
const description = 'Select a pull request to attach review context to this message.';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or #123, or paste pull request URL"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludeDiff((prev) => !prev)}
|
||||
className="h-9 shrink-0 flex items-center gap-1 text-left"
|
||||
aria-pressed={includeDiff}
|
||||
aria-label="Include PR diff in attached context"
|
||||
>
|
||||
<Checkbox
|
||||
checked={includeDiff}
|
||||
onChange={(checked) => setIncludeDiff(checked)}
|
||||
ariaLabel="Include PR diff in attached context"
|
||||
className="size-6"
|
||||
iconClassName="size-5"
|
||||
/>
|
||||
<span className="typography-small text-muted-foreground whitespace-nowrap">Include PR diff</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
|
||||
{!projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
||||
) : null}
|
||||
|
||||
{!github ? (
|
||||
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading pull requests...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connected === false ? (
|
||||
<div className="text-center text-muted-foreground py-8 space-y-3">
|
||||
<div>GitHub not connected. Connect your GitHub account in settings.</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
|
||||
Open settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
|
||||
) : null}
|
||||
|
||||
{directNumber && projectDirectory && github && connected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
loadingPrNumber === directNumber && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => void attachPr(directNumber)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
Use pull request #{directNumber}
|
||||
</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{loadingPrNumber === directNumber ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{query ? 'No pull requests found' : 'No open pull requests found'}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((pr) => (
|
||||
<div
|
||||
key={pr.number}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
loadingPrNumber === pr.number && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => void attachPr(pr.number)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 ml-0.5">
|
||||
<p className="typography-small text-foreground truncate">
|
||||
<span className="text-muted-foreground mr-1">#{pr.number}</span>
|
||||
{pr.title}
|
||||
</p>
|
||||
<p className="typography-meta text-muted-foreground truncate">{pr.head} → {pr.base}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{loadingPrNumber === pr.number ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Open in GitHub"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && connected && projectDirectory && github ? (
|
||||
<div className="py-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore || Boolean(loadingPrNumber)}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||||
(isLoadingMore || Boolean(loadingPrNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={() => onOpenChange(false)}
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-small text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiGithubLine className="h-5 w-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitBranches } from '@/stores/useGitStore';
|
||||
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
@@ -195,7 +195,7 @@ export function NewWorktreeDialog({
|
||||
onOpenChange,
|
||||
onWorktreeCreated,
|
||||
}: NewWorktreeDialogProps) {
|
||||
const { github } = useRuntimeAPIs();
|
||||
const { github, git } = useRuntimeAPIs();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
@@ -230,6 +230,14 @@ export function NewWorktreeDialog({
|
||||
|
||||
// Use cached branches from Git store (instant if already fetched)
|
||||
const branches = useGitBranches(projectDirectory);
|
||||
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory || !git) return;
|
||||
if (branches?.all) return;
|
||||
void fetchBranches(projectDirectory, git);
|
||||
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
|
||||
|
||||
// Compute local and remote branch lists (same pattern as GitView)
|
||||
const localBranches = React.useMemo(() => {
|
||||
@@ -1052,7 +1060,11 @@ Nice-to-have:
|
||||
onClose={() => setExistingBranchPickerOpen(false)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
{isLoadingBranches ? (
|
||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
@@ -1267,7 +1279,11 @@ Nice-to-have:
|
||||
onClose={() => setSourceBranchPickerOpen(false)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
{isLoadingBranches ? (
|
||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
@@ -1438,7 +1454,11 @@ Nice-to-have:
|
||||
<SelectValue placeholder="Choose a branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
{isLoadingBranches ? (
|
||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
@@ -1598,7 +1618,11 @@ Nice-to-have:
|
||||
<SelectValue placeholder="Select source branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
{isLoadingBranches ? (
|
||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user