feat(app): unify composer attachments with pill + lightbox redesign

Images and GitHub issues/PRs now live in one draft-store list (ComposerAttachment union) with cwd persisted on the draft. Paperclip becomes a + drop-up menu; queue button removed. Each pill uses a shared AttachmentPill wrapper with a corner-X that mirrors the auto-update toast spec. Pill body opens the attachment: GitHub via openExternalUrl, images via a new fullscreen AttachmentLightbox. GitHub picker auto-closes on select.
This commit is contained in:
Mohamed Boudra
2026-04-17 11:36:08 +07:00
parent 11524dc349
commit 3838a8b969
25 changed files with 2994 additions and 735 deletions
@@ -208,6 +208,7 @@ function DraftAgentScreenContent({
agentId: draftAgentIdRef.current,
draftId: draftIdRef.current,
}),
initialCwd: resolvedWorkingDir ?? "",
composer: {
initialServerId: resolvedServerId ?? null,
initialValues,
@@ -850,7 +851,7 @@ function DraftAgentScreenContent({
labels: {},
};
},
createRequest: async ({ attempt, text, images }) => {
createRequest: async ({ attempt, text, images, attachments }) => {
const trimmedPath = workingDir.trim();
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
@@ -896,6 +897,7 @@ function DraftAgentScreenContent({
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
git: gitOptions,
});
@@ -1201,8 +1203,9 @@ function DraftAgentScreenContent({
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
images={draftInput.images}
onChangeImages={draftInput.setImages}
attachments={draftInput.attachments}
onChangeAttachments={draftInput.setAttachments}
cwd={draftInput.cwd}
clearDraft={draftInput.clear}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
+168 -251
View File
@@ -3,17 +3,14 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import {
CircleDot,
ChevronDown,
ExternalLink,
GitBranch,
GitPullRequest,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SegmentedControlOption } from "@/components/ui/segmented-control";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
@@ -30,12 +27,10 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AgentAttachment } from "@server/shared/messages";
interface NewWorkspaceScreenProps {
serverId: string;
@@ -43,6 +38,26 @@ interface NewWorkspaceScreenProps {
displayName?: string;
}
type BaseRef = { name: string };
interface Checkout {
action: "checkout" | "branch-off";
ref: BaseRef;
}
function refId(ref: BaseRef): string {
return `branch:${ref.name}`;
}
function refLabel(ref: BaseRef): string {
return ref.name;
}
function formatCheckoutBadge(c: Checkout): string {
const label = c.ref.name;
return c.action === "branch-off" ? `new branch off ${label}` : label;
}
export function NewWorkspaceScreen({
serverId,
sourceDirectory,
@@ -57,13 +72,10 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const branchAnchorRef = useRef<View>(null);
const [selectedGithubItem, setSelectedGithubItem] = useState<GitHubSearchItem | null>(null);
const [githubPickerOpen, setGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const githubAnchorRef = useRef<View>(null);
const [checkout, setCheckout] = useState<Checkout | null>(null);
const [checkoutPickerOpen, setCheckoutPickerOpen] = useState(false);
const [pickerAction, setPickerAction] = useState<Checkout["action"]>("branch-off");
const checkoutAnchorRef = useRef<View>(null);
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
@@ -71,6 +83,7 @@ export function NewWorkspaceScreen({
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `new-workspace:${serverId}:${sourceDirectory}`,
initialCwd: sourceDirectory,
composer: {
initialServerId: serverId || null,
initialValues: workspace?.workspaceDirectory
@@ -110,89 +123,74 @@ export function NewWorkspaceScreen({
enabled: isConnected && !!client,
});
const githubSearchQuery_trimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useQuery({
queryKey: ["github-search", serverId, sourceDirectory, githubSearchQuery_trimmed],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: githubSearchQuery_trimmed,
limit: 20,
});
},
enabled: isConnected && !!client,
staleTime: 30_000,
});
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
const items = githubSearchResultsQuery.data?.items ?? [];
return items.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
// Include search query so the Combobox's client-side filter doesn't
// discard server-searched results that match on body but not title.
description: githubSearchQuery_trimmed,
}));
}, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]);
const handleSelectGithubItem = useCallback(
(id: string) => {
const items = githubSearchResultsQuery.data?.items ?? [];
const [kind, numberStr] = id.split(":");
const item = items.find((i) => i.kind === kind && i.number === Number(numberStr));
setSelectedGithubItem(item ?? null);
setGithubPickerOpen(false);
setGithubSearchQuery("");
},
[githubSearchResultsQuery.data?.items],
);
const branchOptions: ComboboxOptionType[] = useMemo(
() =>
(branchSuggestionsQuery.data?.branches ?? []).map((branch) => ({
id: branch,
label: branch,
})),
const checkoutRefs: BaseRef[] = useMemo(
() => (branchSuggestionsQuery.data?.branches ?? []).map((name): BaseRef => ({ name })),
[branchSuggestionsQuery.data?.branches],
);
const ensureWorkspace = useCallback(async () => {
if (createdWorkspace) {
return createdWorkspace;
}
const checkoutOptions: ComboboxOptionType[] = useMemo(
() =>
checkoutRefs.map((ref) => ({
id: refId(ref),
label: refLabel(ref),
})),
[checkoutRefs],
);
const connectedClient = withConnectedClient();
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
const payload = await connectedClient.createPaseoWorktree({
cwd: sourceDirectory,
worktreeSlug: createNameId(),
...(reviewAttachment ? { attachments: [reviewAttachment] } : {}),
});
const effectiveCheckout: Checkout | null =
checkout ?? (currentBranch ? { action: "branch-off", ref: { name: currentBranch } } : null);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
}
const commitCheckout = useCallback((ref: BaseRef, action: Checkout["action"]) => {
setCheckout({ action, ref });
setCheckoutPickerOpen(false);
}, []);
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(serverId, [normalizedWorkspace]);
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
}, [
createdWorkspace,
mergeWorkspaces,
serverId,
selectedGithubItem,
sourceDirectory,
withConnectedClient,
]);
const openCheckoutPicker = useCallback(() => {
setPickerAction(effectiveCheckout?.action ?? "branch-off");
setCheckoutPickerOpen(true);
}, [effectiveCheckout]);
const checkoutModeOptions: SegmentedControlOption<Checkout["action"]>[] = useMemo(
() => [
{ value: "checkout", label: "Check out" },
{ value: "branch-off", label: "Branch off" },
],
[],
);
const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: AgentAttachment[] }) => {
if (createdWorkspace) {
return createdWorkspace;
}
const connectedClient = withConnectedClient();
const payload = await connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
}
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(serverId, [normalizedWorkspace]);
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
},
[createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
);
const handleCreateChatAgent = useCallback(
async ({ text, images }: MessagePayload) => {
async ({ text, attachments, cwd }: MessagePayload) => {
try {
setPendingAction("chat");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const { images, attachments: reviewAttachments } =
splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Composer state is required");
@@ -200,7 +198,6 @@ export function NewWorkspaceScreen({
const initialPrompt = text.trim();
const encodedImages = await encodeImages(images);
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
@@ -217,7 +214,7 @@ export function NewWorkspaceScreen({
: {}),
...(initialPrompt ? { initialPrompt } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
...(reviewAttachment ? { attachments: [reviewAttachment] } : {}),
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
});
setAgents(serverId, (previous) => {
@@ -239,15 +236,7 @@ export function NewWorkspaceScreen({
setPendingAction(null);
}
},
[
composerState,
ensureWorkspace,
selectedGithubItem,
serverId,
setAgents,
toast,
withConnectedClient,
],
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient],
);
const workspaceTitle =
@@ -289,15 +278,15 @@ export function NewWorkspaceScreen({
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
hasExternalContent={selectedGithubItem !== null}
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
images={chatDraft.images}
onChangeImages={chatDraft.setImages}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={chatDraft.cwd}
clearDraft={() => {
// No-op: screen navigates away on success, text should stay for retry on error
}}
@@ -315,166 +304,74 @@ export function NewWorkspaceScreen({
/>
<View style={styles.optionsRow}>
<View>
<Pressable
ref={branchAnchorRef}
onPress={() => setBranchPickerOpen(true)}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Branch"
>
<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.badgeText} numberOfLines={1}>
{selectedBranch ?? currentBranch ?? "main"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={checkoutAnchorRef}
onPress={openCheckoutPicker}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Checkout ref"
>
<View style={styles.badgeIconBox}>
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{effectiveCheckout ? formatCheckoutBadge(effectiveCheckout) : "main"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Choose where to start from</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={branchOptions}
value={selectedBranch ?? currentBranch ?? ""}
onSelect={(id) => setSelectedBranch(id)}
options={checkoutOptions}
value={effectiveCheckout ? refId(effectiveCheckout.ref) : ""}
onSelect={(id) => {
const ref = checkoutRefs.find((r) => refId(r) === id);
if (ref) commitCheckout(ref, pickerAction);
}}
searchable
searchPlaceholder="Search branches"
title="Branch"
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
title="Start from"
open={checkoutPickerOpen}
onOpenChange={setCheckoutPickerOpen}
desktopPlacement="bottom-start"
anchorRef={branchAnchorRef}
renderOption={({ option, selected, active, onPress }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
}
/>
)}
/>
</View>
<View>
<Pressable
ref={githubAnchorRef}
onPress={() => setGithubPickerOpen(true)}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
]}
accessibilityRole="button"
accessibilityLabel={
selectedGithubItem
? `#${selectedGithubItem.number} ${selectedGithubItem.title}`
: "Add GitHub issue or PR"
}
>
{selectedGithubItem ? (
<>
<View style={styles.badgeIcon}>
{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>
<Combobox
options={githubSearchOptions}
value={
selectedGithubItem
? `${selectedGithubItem.kind}:${selectedGithubItem.number}`
: ""
}
onSelect={handleSelectGithubItem}
searchable
searchPlaceholder="Search issues and PRs..."
title="GitHub"
open={githubPickerOpen}
onOpenChange={(open) => {
setGithubPickerOpen(open);
if (!open) setGithubSearchQuery("");
}}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="bottom-start"
anchorRef={githubAnchorRef}
emptyText={
githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."
anchorRef={checkoutAnchorRef}
emptyText="No branches."
stickyHeader={
<View style={styles.checkoutPickerHeader}>
<SegmentedControl
size="sm"
options={checkoutModeOptions}
value={pickerAction}
onValueChange={setPickerAction}
/>
</View>
}
renderOption={({ option, selected, active, onPress }) => {
const item = (githubSearchResultsQuery.data?.items ?? []).find(
(i) => `${i.kind}:${i.number}` === option.id,
);
const ref = checkoutRefs.find((r) => refId(r) === option.id);
if (!ref) return <View key={option.id} />;
return (
<ComboboxItem
key={option.id}
label={option.label}
label={refLabel(ref)}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
item?.kind === "pr" ? (
<GitPullRequest
<View style={styles.rowIconBox}>
<GitBranch
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)
}
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
</View>
}
/>
);
@@ -561,12 +458,32 @@ const styles = StyleSheet.create((theme) => ({
badgePressed: {
backgroundColor: theme.colors.surface0,
},
badgeIcon: {
flexShrink: 0,
},
badgeText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
checkoutPickerHeader: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
alignItems: "flex-start",
},
badgeIconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
rowIconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -66,6 +66,7 @@ export function WorkspaceDraftAgentTab({
);
const draftInput = useAgentInputDraft({
draftKey: draftStoreKey,
initialCwd: workspaceDirectory ?? "",
composer: {
initialServerId: serverId,
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
@@ -148,7 +149,7 @@ export function WorkspaceDraftAgentTab({
labels: {},
};
},
createRequest: async ({ attempt, text, images }) => {
createRequest: async ({ attempt, text, images, attachments }) => {
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) {
@@ -173,6 +174,7 @@ export function WorkspaceDraftAgentTab({
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
});
return {
@@ -291,8 +293,9 @@ export function WorkspaceDraftAgentTab({
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
images={draftInput.images}
onChangeImages={draftInput.setImages}
attachments={draftInput.attachments}
onChangeAttachments={draftInput.setAttachments}
cwd={draftInput.cwd}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback}