Skip to content
Source
Sign inSign up
Open History to see the last commit

ComposerCommandPopover.tsx

275 lines8.0 KB

1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
5import type {
6 EnvironmentId,
7 EnvironmentMachineKind,
8 PullRequestContextMetadata,
9 ScopedThreadRef,
10 ServerProviderSkill,
11 ServerProviderSlashCommand,
12} from "@t3tools/contracts";
13import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
14import { memo } from "react";
15import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
16​
17import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
18import { AppText as Text } from "../../components/ui/AppText";
19import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol";
20import { GlassSurface } from "../../components/ui/GlassSurface";
21import { PierreEntryIcon } from "../../components/PierreEntryIcon";
22import { ProviderIcon } from "../../components/ProviderIcon";
23import { cn } from "../../lib/cn";
24import type { ModelOption } from "../../lib/modelOptions";
25import { pullRequestStatus } from "../pull-requests/pullRequestStatus";
26export type ComposerCommandItem =
27 | {
28 readonly id: string;
29 readonly type: "pull-request";
30 readonly pullRequest: PullRequestContextMetadata;
31 readonly label: string;
32 readonly description: string;
33 }
34 | {
35 readonly id: string;
36 readonly type: "path";
37 readonly path: string;
38 readonly kind: "file" | "directory";
39 readonly label: string;
40 readonly description: string;
41 }
42 | {
43 readonly id: string;
44 readonly type: "thread";
45 readonly thread: ScopedThreadRef;
46 readonly label: string;
47 readonly description: string;
48 }
49 | {
50 readonly id: string;
51 readonly type: "environment";
52 readonly environmentId: EnvironmentId;
53 readonly machine: EnvironmentMachineKind;
54 readonly label: string;
55 readonly description: string;
56 }
57 | {
58 readonly id: string;
59 readonly type: "slash-command";
60 readonly command: string;
61 readonly label: string;
62 readonly description: string;
63 }
64 | {
65 readonly id: string;
66 readonly type: "provider-slash-command";
67 readonly command: ServerProviderSlashCommand;
68 readonly label: string;
69 readonly description: string;
70 }
71 | {
72 readonly id: string;
73 readonly type: "skill";
74 readonly skill: ServerProviderSkill;
75 readonly label: string;
76 readonly description: string;
77 }
78 | {
79 readonly id: string;
80 readonly type: "model";
81 readonly option: ModelOption;
82 readonly label: string;
83 readonly description: string;
84 };
85​
86interface ComposerCommandPopoverProps {
87 readonly items: ReadonlyArray<ComposerCommandItem>;
88 readonly triggerKind: ComposerTriggerKind | null;
89 readonly isLoading: boolean;
90 readonly error?: string | null;
91 readonly onSelect: (item: ComposerCommandItem) => void;
93​
94function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
95 const baseStyle: ViewStyle = {
96 borderRadius: 16,
97 overflow: "hidden",
98 ...props.style,
99 };
100​
101 return (
102 <GlassSurface
103 glassEffectStyle="clear"
104 tintColorClassName="accent-glass-surface"
105 style={baseStyle}
106 >
107 {props.children}
108 </GlassSurface>
109 );
111​
112const SKILL_SOURCE_SYMBOL_BY_KIND: Record<ProviderSkillSourceKind, AppSymbolName> = {
113 app: "square.grid.2x2",
114 repo: "folder",
115 project: "folder",
116 personal: "person.crop.circle",
117 system: "gearshape",
118 other: "cube",
119};
120​
121function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
122 switch (item.type) {
123 case "pull-request":
124 return pullRequestStatus(item.pullRequest).icon;
125 case "slash-command":
126 case "provider-slash-command":
127 return "terminal";
128 case "skill":
129 return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)];
130 case "path":
131 case "model":
132 return null;
133 case "thread":
134 return "text.bubble";
135 case "environment":
136 return ENVIRONMENT_MACHINE_SYMBOLS[item.machine];
137 }
139​
140function groupLabel(
141 triggerKind: ComposerTriggerKind | null,
142 items: ReadonlyArray<ComposerCommandItem>,
143): string | null {
144 switch (triggerKind) {
145 case "pull-request":
146 return "Pull requests";
147 case "slash-command":
148 return "Commands";
149 case "skill":
150 return "Skills";
151 case "path":
152 return items.every((item) => item.type === "path") ? "Files" : "Mentions";
153 default:
154 return null;
155 }
157​
158function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string {
159 if (isLoading) {
160 return triggerKind === "path" ? "Searching files…" : "Loading…";
161 }
162 switch (triggerKind) {
163 case "pull-request":
164 return "No matching pull requests.";
165 case "path":
166 return "No matching mentions.";
167 case "skill":
168 return "No skills found.";
169 case "slash-command":
170 return "No matching commands.";
171 default:
172 return "No results.";
173 }
175​
176const CommandRow = memo(function CommandRow(props: {
177 readonly item: ComposerCommandItem;
178 readonly onPress: () => void;
179 readonly isLast: boolean;
180 readonly isSlashSkill: boolean;
181}) {
182 const iconName = itemIcon(props.item);
183​
184 return (
185 <Pressable
186 accessibilityRole="button"
187 onPress={props.onPress}
188 className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
189 style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }}
190 >
191 {props.item.type === "path" ? (
192 <PierreEntryIcon path={props.item.path} kind={props.item.kind} size={16} />
193 ) : props.item.type === "model" ? (
194 <ProviderIcon
195 iconUrl={props.item.option.providerIconUrl}
196 provider={props.item.option.providerDriver}
197 size={16}
198 />
199 ) : iconName ? (
200 <SymbolView
201 name={iconName}
202 size={14}
203 tintColorClassName={
204 props.item.type === "pull-request"
205 ? pullRequestStatus(props.item.pullRequest).tint
206 : "accent-icon-subtle"
207 }
208 type="monochrome"
209 />
210 ) : null}
211 <Text
212 className={cn(
213 "shrink-0 text-base font-t3-medium text-foreground",
214 props.item.type === "thread" && "max-w-[45%]",
215 )}
216 numberOfLines={1}
217 >
218 {props.isSlashSkill && props.item.type === "skill" ? (
219 <>
220 <Text className="text-foreground-muted">skill:</Text>
221 {props.item.skill.name}
222 </>
223 ) : (
224 props.item.label
225 )}
226 </Text>
227 {props.item.description ? (
228 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
229 {props.item.description}
230 </Text>
231 ) : null}
232 </Pressable>
233 );
234});
235​
236export const ComposerCommandPopover = memo(function ComposerCommandPopover(
237 props: ComposerCommandPopoverProps,
238) {
239 const label = groupLabel(props.triggerKind, props.items);
240​
241 return (
242 <PopoverSurface>
243 {label ? (
244 <View className="px-3.5 pt-2.5 pb-1">
245 <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
246 {label}
247 </Text>
248 </View>
249 ) : null}
250 {props.items.length > 0 ? (
251 <ScrollView
252 className="max-h-[180px]"
253 keyboardShouldPersistTaps="always"
254 showsVerticalScrollIndicator={false}
255 >
256 {props.items.map((item, index) => (
257 <CommandRow
258 key={item.id}
259 item={item}
260 onPress={() => props.onSelect(item)}
261 isLast={index === props.items.length - 1}
262 isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
263 />
264 ))}
265 </ScrollView>
266 ) : (
267 <View className="px-3.5 py-2.5">
268 <Text className="text-xs text-foreground-tertiary">
269 {props.error ?? emptyText(props.triggerKind, props.isLoading)}
270 </Text>
271 </View>
272 )}
273 </PopoverSurface>
274 );
275});