feat: populate GitHub dropdown on new workspace screen
- Fire GitHub search query on mount instead of waiting for 2+ chars - Add staleTime so combobox reuses cached results on open/close - Single-select: replace multi-item chips with a transforming trigger - Trigger shows issue/PR icon, truncated title, and ExternalLink icon - ExternalLink opens GitHub URL via openExternalUrl (desktop-aware) - Add ExternalLink trailingSlot on each combobox option row - Hover on ExternalLink changes color from foregroundMuted to foreground - Constrain badge width (maxWidth: 240) with flexShrink on text
This commit is contained in:
@@ -3,7 +3,8 @@ import { Pressable, Text, View } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronDown, CircleDot, GitBranch, GitPullRequest, Plus, X } from "lucide-react-native";
|
import { CircleDot, ChevronDown, ExternalLink, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||||
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
|
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
|
||||||
@@ -23,6 +24,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
|||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
requireWorkspaceExecutionAuthority,
|
requireWorkspaceExecutionAuthority,
|
||||||
} from "@/utils/workspace-execution";
|
} from "@/utils/workspace-execution";
|
||||||
@@ -30,13 +32,13 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
|||||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
|
||||||
function buildInitialPrompt(userText: string, githubItems: GitHubSearchItem[]): string {
|
function buildInitialPrompt(userText: string, githubItem: GitHubSearchItem | null): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
for (const item of githubItems) {
|
if (githubItem) {
|
||||||
const kind = item.kind === "pr" ? "Pull Request" : "Issue";
|
const kind = githubItem.kind === "pr" ? "Pull Request" : "Issue";
|
||||||
const header = `GitHub ${kind} #${item.number}: ${item.title}`;
|
const header = `GitHub ${kind} #${githubItem.number}: ${githubItem.title}`;
|
||||||
const body = item.body?.trim();
|
const body = githubItem.body?.trim();
|
||||||
parts.push(body ? `${header}\n\n${body}` : header);
|
parts.push(body ? `${header}\n\n${body}` : header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ export function NewWorkspaceScreen({
|
|||||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||||
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
|
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
|
||||||
const branchAnchorRef = useRef<View>(null);
|
const branchAnchorRef = useRef<View>(null);
|
||||||
const [githubContextItems, setGithubContextItems] = useState<GitHubSearchItem[]>([]);
|
const [selectedGithubItem, setSelectedGithubItem] = useState<GitHubSearchItem | null>(null);
|
||||||
const [githubPickerOpen, setGithubPickerOpen] = useState(false);
|
const [githubPickerOpen, setGithubPickerOpen] = useState(false);
|
||||||
const [githubSearchQuery, setGithubSearchQuery] = useState("");
|
const [githubSearchQuery, setGithubSearchQuery] = useState("");
|
||||||
const githubAnchorRef = useRef<View>(null);
|
const githubAnchorRef = useRef<View>(null);
|
||||||
@@ -131,14 +133,13 @@ export function NewWorkspaceScreen({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: isConnected && !!client && githubSearchQuery_trimmed.length >= 2,
|
enabled: isConnected && !!client,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
|
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
|
||||||
const items = githubSearchResultsQuery.data?.items ?? [];
|
const items = githubSearchResultsQuery.data?.items ?? [];
|
||||||
const selectedNumbers = new Set(githubContextItems.map((i) => `${i.kind}:${i.number}`));
|
|
||||||
return items
|
return items
|
||||||
.filter((item) => !selectedNumbers.has(`${item.kind}:${item.number}`))
|
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: `${item.kind}:${item.number}`,
|
id: `${item.kind}:${item.number}`,
|
||||||
label: `#${item.number} ${item.title}`,
|
label: `#${item.number} ${item.title}`,
|
||||||
@@ -146,26 +147,20 @@ export function NewWorkspaceScreen({
|
|||||||
// discard server-searched results that match on body but not title.
|
// discard server-searched results that match on body but not title.
|
||||||
description: githubSearchQuery_trimmed,
|
description: githubSearchQuery_trimmed,
|
||||||
}));
|
}));
|
||||||
}, [githubSearchResultsQuery.data?.items, githubContextItems, githubSearchQuery_trimmed]);
|
}, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]);
|
||||||
|
|
||||||
const handleSelectGithubItem = useCallback(
|
const handleSelectGithubItem = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const items = githubSearchResultsQuery.data?.items ?? [];
|
const items = githubSearchResultsQuery.data?.items ?? [];
|
||||||
const [kind, numberStr] = id.split(":");
|
const [kind, numberStr] = id.split(":");
|
||||||
const item = items.find((i) => i.kind === kind && i.number === Number(numberStr));
|
const item = items.find((i) => i.kind === kind && i.number === Number(numberStr));
|
||||||
if (item) {
|
setSelectedGithubItem(item ?? null);
|
||||||
setGithubContextItems((prev) => [...prev, item]);
|
|
||||||
}
|
|
||||||
setGithubPickerOpen(false);
|
setGithubPickerOpen(false);
|
||||||
setGithubSearchQuery("");
|
setGithubSearchQuery("");
|
||||||
},
|
},
|
||||||
[githubSearchResultsQuery.data?.items],
|
[githubSearchResultsQuery.data?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveGithubItem = useCallback((index: number) => {
|
|
||||||
setGithubContextItems((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const branchOptions: ComboboxOptionType[] = useMemo(
|
const branchOptions: ComboboxOptionType[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(branchSuggestionsQuery.data?.branches ?? []).map((branch) => ({
|
(branchSuggestionsQuery.data?.branches ?? []).map((branch) => ({
|
||||||
@@ -213,7 +208,7 @@ export function NewWorkspaceScreen({
|
|||||||
throw new Error("Composer state is required");
|
throw new Error("Composer state is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialPrompt = buildInitialPrompt(text.trim(), githubContextItems);
|
const initialPrompt = buildInitialPrompt(text.trim(), selectedGithubItem);
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
||||||
const agent = await connectedClient.createAgent({
|
const agent = await connectedClient.createAgent({
|
||||||
@@ -250,7 +245,7 @@ export function NewWorkspaceScreen({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerState, ensureWorkspace, githubContextItems, serverId, setAgents, toast, withConnectedClient],
|
[composerState, ensureWorkspace, selectedGithubItem, serverId, setAgents, toast, withConnectedClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceTitle =
|
const workspaceTitle =
|
||||||
@@ -362,25 +357,6 @@ export function NewWorkspaceScreen({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{githubContextItems.map((item, index) => (
|
|
||||||
<View key={`${item.kind}:${item.number}`} style={styles.githubChip}>
|
|
||||||
{item.kind === "pr" ? (
|
|
||||||
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.githubChipText} numberOfLines={1}>
|
|
||||||
#{item.number} {item.title}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => handleRemoveGithubItem(index)}
|
|
||||||
accessibilityLabel={`Remove #${item.number}`}
|
|
||||||
hitSlop={4}
|
|
||||||
>
|
|
||||||
<X size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={githubAnchorRef}
|
ref={githubAnchorRef}
|
||||||
@@ -391,16 +367,51 @@ export function NewWorkspaceScreen({
|
|||||||
pressed && styles.badgePressed,
|
pressed && styles.badgePressed,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Add GitHub issue or PR"
|
accessibilityLabel={
|
||||||
|
selectedGithubItem
|
||||||
|
? `#${selectedGithubItem.number} ${selectedGithubItem.title}`
|
||||||
|
: "Add GitHub issue or PR"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{selectedGithubItem ? (
|
||||||
<Text style={styles.badgeText} numberOfLines={1}>
|
<>
|
||||||
Issue or PR
|
<View style={styles.badgeIcon}>
|
||||||
</Text>
|
{selectedGithubItem.kind === "pr" ? (
|
||||||
|
<GitPullRequest size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.badgeText} numberOfLines={1}>
|
||||||
|
#{selectedGithubItem.number} {selectedGithubItem.title}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => openExternalUrl(selectedGithubItem.url)}
|
||||||
|
accessibilityLabel="Open on GitHub"
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<ExternalLink
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.badgeIcon}>
|
||||||
|
<GitHubIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.badgeText} numberOfLines={1}>
|
||||||
|
Issue or PR
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={githubSearchOptions}
|
options={githubSearchOptions}
|
||||||
value=""
|
value={selectedGithubItem ? `${selectedGithubItem.kind}:${selectedGithubItem.number}` : ""}
|
||||||
onSelect={handleSelectGithubItem}
|
onSelect={handleSelectGithubItem}
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder="Search issues and PRs..."
|
searchPlaceholder="Search issues and PRs..."
|
||||||
@@ -414,11 +425,9 @@ export function NewWorkspaceScreen({
|
|||||||
desktopPlacement="bottom-start"
|
desktopPlacement="bottom-start"
|
||||||
anchorRef={githubAnchorRef}
|
anchorRef={githubAnchorRef}
|
||||||
emptyText={
|
emptyText={
|
||||||
githubSearchQuery_trimmed.length < 2
|
githubSearchResultsQuery.isFetching
|
||||||
? "Type to search..."
|
? "Searching..."
|
||||||
: githubSearchResultsQuery.isFetching
|
: "No results found."
|
||||||
? "Searching..."
|
|
||||||
: "No results found."
|
|
||||||
}
|
}
|
||||||
renderOption={({ option, selected, active, onPress }) => {
|
renderOption={({ option, selected, active, onPress }) => {
|
||||||
const item = (githubSearchResultsQuery.data?.items ?? []).find(
|
const item = (githubSearchResultsQuery.data?.items ?? []).find(
|
||||||
@@ -444,6 +453,22 @@ export function NewWorkspaceScreen({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
trailingSlot={
|
||||||
|
item?.url ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => openExternalUrl(item.url)}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel="Open on GitHub"
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<ExternalLink
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -518,6 +543,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
height: 28,
|
height: 28,
|
||||||
|
maxWidth: 240,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
@@ -528,24 +554,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
badgePressed: {
|
badgePressed: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
badgeIcon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
|
||||||
githubChip: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
height: 28,
|
|
||||||
paddingLeft: theme.spacing[2],
|
|
||||||
paddingRight: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
maxWidth: 240,
|
|
||||||
},
|
|
||||||
githubChipText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user