- Suggest files, skills, and commands from composer triggers - Show git action progress, success, and error feedback
Unified · Split
| @@ -0,0 +1,211 @@ | ||
| 1 | +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | |
| 2 | +import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; | |
| 3 | +import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | |
| 4 | +import { SymbolView } from "expo-symbols"; | |
| 5 | +import { memo } from "react"; | |
| 6 | +import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; | |
| 7 | + | |
| 8 | +import { AppText as Text } from "../../components/AppText"; | |
| 9 | + | |
| 10 | +export type ComposerCommandItem = | |
| 11 | + | { | |
| 12 | + readonly id: string; | |
| 13 | + readonly type: "path"; | |
| 14 | + readonly path: string; | |
| 15 | + readonly kind: "file" | "directory"; | |
| 16 | + readonly label: string; | |
| 17 | + readonly description: string; | |
| 18 | + } | |
| 19 | + | { | |
| 20 | + readonly id: string; | |
| 21 | + readonly type: "slash-command"; | |
| 22 | + readonly command: string; | |
| 23 | + readonly label: string; | |
| 24 | + readonly description: string; | |
| 25 | + } | |
| 26 | + | { | |
| 27 | + readonly id: string; | |
| 28 | + readonly type: "provider-slash-command"; | |
| 29 | + readonly command: ServerProviderSlashCommand; | |
| 30 | + readonly label: string; | |
| 31 | + readonly description: string; | |
| 32 | + } | |
| 33 | + | { | |
| 34 | + readonly id: string; | |
| 35 | + readonly type: "skill"; | |
| 36 | + readonly skill: ServerProviderSkill; | |
| 37 | + readonly label: string; | |
| 38 | + readonly description: string; | |
| 39 | + }; | |
| 40 | + | |
| 41 | +interface ComposerCommandPopoverProps { | |
| 42 | + readonly items: ReadonlyArray<ComposerCommandItem>; | |
| 43 | + readonly triggerKind: ComposerTriggerKind | null; | |
| 44 | + readonly isLoading: boolean; | |
| 45 | + readonly onSelect: (item: ComposerCommandItem) => void; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function PopoverSurface(props: { | |
| 49 | + readonly children: React.ReactNode; | |
| 50 | + readonly isDarkMode: boolean; | |
| 51 | + readonly style?: ViewStyle; | |
| 52 | +}) { | |
| 53 | + const baseStyle: ViewStyle = { | |
| 54 | + borderRadius: 16, | |
| 55 | + overflow: "hidden", | |
| 56 | + ...props.style, | |
| 57 | + }; | |
| 58 | + | |
| 59 | + if (isLiquidGlassSupported) { | |
| 60 | + return ( | |
| 61 | + <LiquidGlassView | |
| 62 | + effect="clear" | |
| 63 | + interactive={false} | |
| 64 | + tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"} | |
| 65 | + colorScheme={props.isDarkMode ? "dark" : "light"} | |
| 66 | + style={baseStyle} | |
| 67 | + > | |
| 68 | + {props.children} | |
| 69 | + </LiquidGlassView> | |
| 70 | + ); | |
| 71 | + } | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <View | |
| 75 | + style={[ | |
| 76 | + baseStyle, | |
| 77 | + { | |
| 78 | + backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | |
| 79 | + borderWidth: 1, | |
| 80 | + borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | |
| 81 | + }, | |
| 82 | + ]} | |
| 83 | + > | |
| 84 | + {props.children} | |
| 85 | + </View> | |
| 86 | + ); | |
| 87 | +} | |
| 88 | + | |
| 89 | +function itemIcon(item: ComposerCommandItem) { | |
| 90 | + switch (item.type) { | |
| 91 | + case "path": | |
| 92 | + return item.kind === "directory" ? ("folder" as const) : ("doc" as const); | |
| 93 | + case "slash-command": | |
| 94 | + case "provider-slash-command": | |
| 95 | + return "terminal" as const; | |
| 96 | + case "skill": | |
| 97 | + return "cube" as const; | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { | |
| 102 | + switch (triggerKind) { | |
| 103 | + case "slash-command": | |
| 104 | + return "Commands"; | |
| 105 | + case "skill": | |
| 106 | + return "Skills"; | |
| 107 | + case "path": | |
| 108 | + return "Files"; | |
| 109 | + default: | |
| 110 | + return null; | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { | |
| 115 | + if (isLoading) { | |
| 116 | + return triggerKind === "path" ? "Searching files…" : "Loading…"; | |
| 117 | + } | |
| 118 | + switch (triggerKind) { | |
| 119 | + case "path": | |
| 120 | + return "No matching files or folders."; | |
| 121 | + case "skill": | |
| 122 | + return "No skills found."; | |
| 123 | + case "slash-command": | |
| 124 | + return "No matching commands."; | |
| 125 | + default: | |
| 126 | + return "No results."; | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +const CommandRow = memo(function CommandRow(props: { | |
| 131 | + readonly item: ComposerCommandItem; | |
| 132 | + readonly onPress: () => void; | |
| 133 | + readonly isLast: boolean; | |
| 134 | +}) { | |
| 135 | + const iconName = itemIcon(props.item); | |
| 136 | + const iconColor = "#a1a1aa"; | |
| 137 | + | |
| 138 | + return ( | |
| 139 | + <Pressable | |
| 140 | + onPress={props.onPress} | |
| 141 | + style={({ pressed }) => ({ | |
| 142 | + flexDirection: "row", | |
| 143 | + alignItems: "center", | |
| 144 | + paddingHorizontal: 14, | |
| 145 | + paddingVertical: 10, | |
| 146 | + gap: 10, | |
| 147 | + opacity: pressed ? 0.6 : 1, | |
| 148 | + borderBottomWidth: props.isLast ? 0 : 0.5, | |
| 149 | + borderBottomColor: "rgba(255,255,255,0.1)", | |
| 150 | + })} | |
| 151 | + > | |
| 152 | + <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | |
| 153 | + <Text | |
| 154 | + className="text-[14px] font-t3-medium text-foreground" | |
| 155 | + numberOfLines={1} | |
| 156 | + style={{ flexShrink: 0 }} | |
| 157 | + > | |
| 158 | + {props.item.label} | |
| 159 | + </Text> | |
| 160 | + {props.item.description ? ( | |
| 161 | + <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, fontSize: 12, color: "#a1a1aa" }}> | |
| 162 | + {props.item.description} | |
| 163 | + </Text> | |
| 164 | + ) : null} | |
| 165 | + </Pressable> | |
| 166 | + ); | |
| 167 | +}); | |
| 168 | + | |
| 169 | +export const ComposerCommandPopover = memo(function ComposerCommandPopover( | |
| 170 | + props: ComposerCommandPopoverProps, | |
| 171 | +) { | |
| 172 | + const isDarkMode = useColorScheme() === "dark"; | |
| 173 | + const label = groupLabel(props.triggerKind); | |
| 174 | + | |
| 175 | + return ( | |
| 176 | + <PopoverSurface isDarkMode={isDarkMode}> | |
| 177 | + {label ? ( | |
| 178 | + <View style={{ paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 }}> | |
| 179 | + <Text | |
| 180 | + className="text-[10px] font-t3-bold text-foreground-muted" | |
| 181 | + style={{ letterSpacing: 0.8, textTransform: "uppercase" }} | |
| 182 | + > | |
| 183 | + {label} | |
| 184 | + </Text> | |
| 185 | + </View> | |
| 186 | + ) : null} | |
| 187 | + {props.items.length > 0 ? ( | |
| 188 | + <ScrollView | |
| 189 | + style={{ maxHeight: 180 }} | |
| 190 | + keyboardShouldPersistTaps="always" | |
| 191 | + showsVerticalScrollIndicator={false} | |
| 192 | + > | |
| 193 | + {props.items.map((item, index) => ( | |
| 194 | + <CommandRow | |
| 195 | + key={item.id} | |
| 196 | + item={item} | |
| 197 | + onPress={() => props.onSelect(item)} | |
| 198 | + isLast={index === props.items.length - 1} | |
| 199 | + /> | |
| 200 | + ))} | |
| 201 | + </ScrollView> | |
| 202 | + ) : ( | |
| 203 | + <View style={{ paddingHorizontal: 14, paddingVertical: 10 }}> | |
| 204 | + <Text className="text-[12px] text-foreground-tertiary"> | |
| 205 | + {emptyText(props.triggerKind, props.isLoading)} | |
| 206 | + </Text> | |
| 207 | + </View> | |
| 208 | + )} | |
| 209 | + </PopoverSurface> | |
| 210 | + ); | |
| 211 | +}); | |
| @@ -0,0 +1,121 @@ | ||
| 1 | +import * as Haptics from "expo-haptics"; | |
| 2 | +import { SymbolView } from "expo-symbols"; | |
| 3 | +import { useCallback, useEffect, useRef } from "react"; | |
| 4 | +import { ActivityIndicator, Linking, Pressable, View } from "react-native"; | |
| 5 | +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; | |
| 6 | +import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 7 | + | |
| 8 | +import { AppText as Text } from "../../components/AppText"; | |
| 9 | +import { useThemeColor } from "../../lib/useThemeColor"; | |
| 10 | +import type { GitActionProgress } from "../../state/use-git-action-state"; | |
| 11 | + | |
| 12 | +export function GitActionProgressOverlay(props: { | |
| 13 | + readonly progress: GitActionProgress; | |
| 14 | + readonly onDismiss: () => void; | |
| 15 | +}) { | |
| 16 | + const { progress, onDismiss } = props; | |
| 17 | + const insets = useSafeAreaInsets(); | |
| 18 | + const prevPhaseRef = useRef(progress.phase); | |
| 19 | + | |
| 20 | + useEffect(() => { | |
| 21 | + const prev = prevPhaseRef.current; | |
| 22 | + prevPhaseRef.current = progress.phase; | |
| 23 | + | |
| 24 | + if (prev === "running" && progress.phase === "success") { | |
| 25 | + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); | |
| 26 | + } else if (prev === "running" && progress.phase === "error") { | |
| 27 | + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); | |
| 28 | + } | |
| 29 | + }, [progress.phase]); | |
| 30 | + | |
| 31 | + const handlePress = useCallback(() => { | |
| 32 | + if (progress.prUrl) { | |
| 33 | + void Linking.openURL(progress.prUrl); | |
| 34 | + return; | |
| 35 | + } | |
| 36 | + if (progress.phase === "success" || progress.phase === "error") { | |
| 37 | + onDismiss(); | |
| 38 | + } | |
| 39 | + }, [onDismiss, progress.phase, progress.prUrl]); | |
| 40 | + | |
| 41 | + if (progress.phase === "idle") { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <Animated.View | |
| 47 | + entering={FadeIn.duration(200)} | |
| 48 | + exiting={FadeOut.duration(150)} | |
| 49 | + style={{ top: insets.top + 48, left: 12, right: 12, position: "absolute", zIndex: 100 }} | |
| 50 | + pointerEvents="box-none" | |
| 51 | + > | |
| 52 | + <Pressable onPress={handlePress}> | |
| 53 | + <OverlayContent progress={progress} /> | |
| 54 | + </Pressable> | |
| 55 | + </Animated.View> | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function OverlayContent(props: { readonly progress: GitActionProgress }) { | |
| 60 | + const { progress } = props; | |
| 61 | + const iconColor = useThemeColor("--color-icon"); | |
| 62 | + | |
| 63 | + const bgClass = | |
| 64 | + progress.phase === "error" | |
| 65 | + ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" | |
| 66 | + : "bg-card border-border"; | |
| 67 | + | |
| 68 | + return ( | |
| 69 | + <View | |
| 70 | + className={`flex-row items-center gap-2.5 rounded-2xl border px-3.5 py-3 shadow-lg shadow-black/10 ${bgClass}`} | |
| 71 | + > | |
| 72 | + <OverlayIcon phase={progress.phase} iconColor={iconColor} /> | |
| 73 | + | |
| 74 | + <View className="flex-1 gap-0.5"> | |
| 75 | + {progress.label ? ( | |
| 76 | + <Text className="text-[13px] font-t3-bold text-foreground" numberOfLines={1}> | |
| 77 | + {progress.label} | |
| 78 | + </Text> | |
| 79 | + ) : null} | |
| 80 | + {progress.description ? ( | |
| 81 | + <Text className="text-[11px] text-foreground-muted" numberOfLines={1}> | |
| 82 | + {progress.description} | |
| 83 | + </Text> | |
| 84 | + ) : null} | |
| 85 | + </View> | |
| 86 | + | |
| 87 | + {progress.prUrl ? ( | |
| 88 | + <SymbolView name="arrow.up.right" size={13} tintColor={iconColor} type="monochrome" /> | |
| 89 | + ) : null} | |
| 90 | + </View> | |
| 91 | + ); | |
| 92 | +} | |
| 93 | + | |
| 94 | +function OverlayIcon(props: { | |
| 95 | + readonly phase: GitActionProgress["phase"]; | |
| 96 | + readonly iconColor: ReturnType<typeof useThemeColor>; | |
| 97 | +}) { | |
| 98 | + switch (props.phase) { | |
| 99 | + case "running": | |
| 100 | + return <ActivityIndicator size="small" />; | |
| 101 | + case "success": | |
| 102 | + return ( | |
| 103 | + <View className="h-6 w-6 items-center justify-center rounded-full bg-green-500"> | |
| 104 | + <SymbolView name="checkmark" size={12} tintColor="white" type="monochrome" /> | |
| 105 | + </View> | |
| 106 | + ); | |
| 107 | + case "error": | |
| 108 | + return ( | |
| 109 | + <View className="h-6 w-6 items-center justify-center rounded-full bg-red-500"> | |
| 110 | + <SymbolView | |
| 111 | + name="exclamationmark.triangle" | |
| 112 | + size={12} | |
| 113 | + tintColor="white" | |
| 114 | + type="monochrome" | |
| 115 | + /> | |
| 116 | + </View> | |
| 117 | + ); | |
| 118 | + default: | |
| 119 | + return null; | |
| 120 | + } | |
| 121 | +} | |
| @@ -8,6 +8,11 @@ import type { · 7 unmodified lines | ||
| 8 | 8 | ServerConfig as T3ServerConfig, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; |
| 11 | +import { | |
| 12 | + detectComposerTrigger, | |
| 13 | + replaceTextRange, | |
| 14 | + type ComposerTrigger, | |
| 15 | +} from "@t3tools/shared/composerTrigger"; | |
| 11 | 16 | import { TextInputWrapper } from "expo-paste-input"; |
| 12 | 17 | import type { ReactNode } from "react"; |
| 13 | 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| @@ -17,6 +22,8 @@ import { · 3 unmodified lines | ||
| 17 | 22 | TextInput as RNTextInput, |
| 18 | 23 | useColorScheme, |
| 19 | 24 | View, |
| 25 | + type NativeSyntheticEvent, | |
| 26 | + type TextInputSelectionChangeEventData, | |
| 20 | 27 | type ViewStyle, |
| 21 | 28 | } from "react-native"; |
| 22 | 29 | import ImageViewing from "react-native-image-viewing"; |
| @@ -30,6 +37,13 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 7 unmodified lines | ||
| 30 | 37 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 31 | 38 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 32 | 39 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| 40 | +import { | |
| 41 | + insertRankedSearchResult, | |
| 42 | + normalizeSearchQuery, | |
| 43 | + scoreQueryMatch, | |
| 44 | +} from "@t3tools/shared/searchRanking"; | |
| 45 | +import { getEnvironmentClient } from "../../state/use-remote-environment-registry"; | |
| 46 | +import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | |
| 33 | 47 | |
| 34 | 48 | /** |
| 35 | 49 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| @@ -53,6 +67,8 @@ export interface ThreadComposerProps { · 17 unmodified lines | ||
| 53 | 67 | readonly serverConfig: T3ServerConfig | null; |
| 54 | 68 | readonly queueCount: number; |
| 55 | 69 | readonly activeThreadBusy: boolean; |
| 70 | + readonly environmentId: string; | |
| 71 | + readonly projectCwd: string | null; | |
| 56 | 72 | readonly onChangeDraftMessage: (value: string) => void; |
| 57 | 73 | readonly onPickDraftImages: () => Promise<void>; |
| 58 | 74 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| @@ -167,6 +183,281 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 108 unmodified lines | ||
| 167 | 183 | void props.onNativePasteImages(uris); |
| 168 | 184 | }); |
| 169 | 185 | |
| 186 | + // ── Trigger detection ──────────────────────────────────── | |
| 187 | + const [cursorPosition, setCursorPosition] = useState(0); | |
| 188 | + const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(null); | |
| 189 | + const [pathSearchResults, setPathSearchResults] = useState< | |
| 190 | + ReadonlyArray<{ path: string; kind: "file" | "directory" }> | |
| 191 | + >([]); | |
| 192 | + const [pathSearchLoading, setPathSearchLoading] = useState(false); | |
| 193 | + const pathSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 194 | + | |
| 195 | + const handleSelectionChange = useCallback( | |
| 196 | + (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | |
| 197 | + const { start } = event.nativeEvent.selection; | |
| 198 | + setCursorPosition(start); | |
| 199 | + }, | |
| 200 | + [], | |
| 201 | + ); | |
| 202 | + | |
| 203 | + useEffect(() => { | |
| 204 | + const trigger = detectComposerTrigger(props.draftMessage, cursorPosition); | |
| 205 | + setComposerTrigger(trigger); | |
| 206 | + }, [props.draftMessage, cursorPosition]); | |
| 207 | + | |
| 208 | + // ── File search (debounced) ────────────────────────────── | |
| 209 | + useEffect(() => { | |
| 210 | + if (pathSearchTimerRef.current) { | |
| 211 | + clearTimeout(pathSearchTimerRef.current); | |
| 212 | + pathSearchTimerRef.current = null; | |
| 213 | + } | |
| 214 | + | |
| 215 | + if (composerTrigger?.kind !== "path" || !composerTrigger.query || !props.projectCwd) { | |
| 216 | + setPathSearchResults([]); | |
| 217 | + setPathSearchLoading(false); | |
| 218 | + return; | |
| 219 | + } | |
| 220 | + | |
| 221 | + setPathSearchLoading(true); | |
| 222 | + const query = composerTrigger.query; | |
| 223 | + const cwd = props.projectCwd; | |
| 224 | + const envId = props.environmentId; | |
| 225 | + | |
| 226 | + pathSearchTimerRef.current = setTimeout(() => { | |
| 227 | + const client = getEnvironmentClient(envId); | |
| 228 | + if (!client) { | |
| 229 | + setPathSearchLoading(false); | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + | |
| 233 | + void client.projects | |
| 234 | + .searchEntries({ cwd, query, limit: 20 }) | |
| 235 | + .then((result) => { | |
| 236 | + setPathSearchResults( | |
| 237 | + result.entries.map((entry) => ({ | |
| 238 | + path: entry.path, | |
| 239 | + kind: entry.kind === "directory" ? ("directory" as const) : ("file" as const), | |
| 240 | + })), | |
| 241 | + ); | |
| 242 | + }) | |
| 243 | + .catch(() => { | |
| 244 | + setPathSearchResults([]); | |
| 245 | + }) | |
| 246 | + .finally(() => { | |
| 247 | + setPathSearchLoading(false); | |
| 248 | + }); | |
| 249 | + }, 200); | |
| 250 | + | |
| 251 | + return () => { | |
| 252 | + if (pathSearchTimerRef.current) { | |
| 253 | + clearTimeout(pathSearchTimerRef.current); | |
| 254 | + } | |
| 255 | + }; | |
| 256 | + }, [composerTrigger?.kind, composerTrigger?.query, props.projectCwd, props.environmentId]); | |
| 257 | + | |
| 258 | + // ── Build menu items ───────────────────────────────────── | |
| 259 | + const selectedProviderStatus = useMemo(() => { | |
| 260 | + if (!props.serverConfig) return null; | |
| 261 | + return ( | |
| 262 | + props.serverConfig.providers.find( | |
| 263 | + (p) => p.provider === props.selectedThread.modelSelection.provider, | |
| 264 | + ) ?? null | |
| 265 | + ); | |
| 266 | + }, [props.serverConfig, props.selectedThread.modelSelection.provider]); | |
| 267 | + | |
| 268 | + const composerMenuItems: ComposerCommandItem[] = useMemo(() => { | |
| 269 | + if (!composerTrigger) return []; | |
| 270 | + | |
| 271 | + if (composerTrigger.kind === "slash-command") { | |
| 272 | + const q = composerTrigger.query.toLowerCase(); | |
| 273 | + const allBuiltIn = [ | |
| 274 | + { | |
| 275 | + id: "cmd:model", | |
| 276 | + type: "slash-command" as const, | |
| 277 | + command: "model", | |
| 278 | + label: "/model", | |
| 279 | + description: "Switch model", | |
| 280 | + }, | |
| 281 | + { | |
| 282 | + id: "cmd:plan", | |
| 283 | + type: "slash-command" as const, | |
| 284 | + command: "plan", | |
| 285 | + label: "/plan", | |
| 286 | + description: "Switch to plan mode", | |
| 287 | + }, | |
| 288 | + { | |
| 289 | + id: "cmd:default", | |
| 290 | + type: "slash-command" as const, | |
| 291 | + command: "default", | |
| 292 | + label: "/default", | |
| 293 | + description: "Switch to default mode", | |
| 294 | + }, | |
| 295 | + ]; | |
| 296 | + const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); | |
| 297 | + | |
| 298 | + const providerCommands: ComposerCommandItem[] = (selectedProviderStatus?.slashCommands ?? []) | |
| 299 | + .filter((cmd) => cmd.name.toLowerCase().includes(q)) | |
| 300 | + .map((cmd) => ({ | |
| 301 | + id: `pcmd:${cmd.name}`, | |
| 302 | + type: "provider-slash-command" as const, | |
| 303 | + command: cmd, | |
| 304 | + label: `/${cmd.name}`, | |
| 305 | + description: cmd.description ?? "", | |
| 306 | + })); | |
| 307 | + | |
| 308 | + return [...builtIn, ...providerCommands]; | |
| 309 | + } | |
| 310 | + | |
| 311 | + if (composerTrigger.kind === "skill") { | |
| 312 | + const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); | |
| 313 | + const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { | |
| 314 | + trimLeadingPattern: /^\$+/, | |
| 315 | + }); | |
| 316 | + | |
| 317 | + if (!normalizedQuery) { | |
| 318 | + return enabledSkills.slice(0, 20).map((skill) => ({ | |
| 319 | + id: `skill:${skill.name}`, | |
| 320 | + type: "skill" as const, | |
| 321 | + skill, | |
| 322 | + label: skill.displayName ?? skill.name, | |
| 323 | + description: skill.shortDescription ?? skill.description ?? "", | |
| 324 | + })); | |
| 325 | + } | |
| 326 | + | |
| 327 | + const ranked: Array<{ | |
| 328 | + item: (typeof enabledSkills)[number]; | |
| 329 | + score: number; | |
| 330 | + tieBreaker: string; | |
| 331 | + }> = []; | |
| 332 | + for (const skill of enabledSkills) { | |
| 333 | + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); | |
| 334 | + const scores = [ | |
| 335 | + scoreQueryMatch({ | |
| 336 | + value: skill.name.toLowerCase(), | |
| 337 | + query: normalizedQuery, | |
| 338 | + exactBase: 0, | |
| 339 | + prefixBase: 2, | |
| 340 | + boundaryBase: 4, | |
| 341 | + includesBase: 6, | |
| 342 | + fuzzyBase: 100, | |
| 343 | + boundaryMarkers: ["-", "_", "/"], | |
| 344 | + }), | |
| 345 | + scoreQueryMatch({ | |
| 346 | + value: displayLabel, | |
| 347 | + query: normalizedQuery, | |
| 348 | + exactBase: 1, | |
| 349 | + prefixBase: 3, | |
| 350 | + boundaryBase: 5, | |
| 351 | + includesBase: 7, | |
| 352 | + fuzzyBase: 110, | |
| 353 | + }), | |
| 354 | + scoreQueryMatch({ | |
| 355 | + value: skill.shortDescription?.toLowerCase() ?? "", | |
| 356 | + query: normalizedQuery, | |
| 357 | + exactBase: 20, | |
| 358 | + prefixBase: 22, | |
| 359 | + boundaryBase: 24, | |
| 360 | + includesBase: 26, | |
| 361 | + }), | |
| 362 | + scoreQueryMatch({ | |
| 363 | + value: skill.description?.toLowerCase() ?? "", | |
| 364 | + query: normalizedQuery, | |
| 365 | + exactBase: 30, | |
| 366 | + prefixBase: 32, | |
| 367 | + boundaryBase: 34, | |
| 368 | + includesBase: 36, | |
| 369 | + }), | |
| 370 | + ].filter((s): s is number => s !== null); | |
| 371 | + | |
| 372 | + if (scores.length > 0) { | |
| 373 | + insertRankedSearchResult( | |
| 374 | + ranked, | |
| 375 | + { | |
| 376 | + item: skill, | |
| 377 | + score: Math.min(...scores), | |
| 378 | + tieBreaker: `${displayLabel}\u0000${skill.name}`, | |
| 379 | + }, | |
| 380 | + 20, | |
| 381 | + ); | |
| 382 | + } | |
| 383 | + } | |
| 384 | + | |
| 385 | + return ranked.map(({ item: skill }) => ({ | |
| 386 | + id: `skill:${skill.name}`, | |
| 387 | + type: "skill" as const, | |
| 388 | + skill, | |
| 389 | + label: skill.displayName ?? skill.name, | |
| 390 | + description: skill.shortDescription ?? skill.description ?? "", | |
| 391 | + })); | |
| 392 | + } | |
| 393 | + | |
| 394 | + if (composerTrigger.kind === "path") { | |
| 395 | + return pathSearchResults.map((entry) => { | |
| 396 | + const parts = entry.path.split("/"); | |
| 397 | + return { | |
| 398 | + id: `path:${entry.path}`, | |
| 399 | + type: "path" as const, | |
| 400 | + path: entry.path, | |
| 401 | + kind: entry.kind, | |
| 402 | + label: parts[parts.length - 1] ?? entry.path, | |
| 403 | + description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", | |
| 404 | + }; | |
| 405 | + }); | |
| 406 | + } | |
| 407 | + | |
| 408 | + return []; | |
| 409 | + }, [composerTrigger, selectedProviderStatus, pathSearchResults]); | |
| 410 | + | |
| 411 | + // ── Handle command selection ────────────────────────────── | |
| 412 | + const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | |
| 413 | + | |
| 414 | + const handleSend = useCallback(() => { | |
| 415 | + onSendMessage(); | |
| 416 | + inputRef.current?.blur(); | |
| 417 | + }, [onSendMessage]); | |
| 418 | + const handleCommandSelect = useCallback( | |
| 419 | + (item: ComposerCommandItem) => { | |
| 420 | + if (!composerTrigger) return; | |
| 421 | + | |
| 422 | + if ( | |
| 423 | + item.type === "slash-command" && | |
| 424 | + (item.command === "plan" || item.command === "default") | |
| 425 | + ) { | |
| 426 | + const result = replaceTextRange( | |
| 427 | + draftMessage, | |
| 428 | + composerTrigger.rangeStart, | |
| 429 | + composerTrigger.rangeEnd, | |
| 430 | + "", | |
| 431 | + ); | |
| 432 | + setCursorPosition(result.cursor); | |
| 433 | + onChangeDraftMessage(result.text); | |
| 434 | + void onUpdateInteractionMode(item.command); | |
| 435 | + return; | |
| 436 | + } | |
| 437 | + | |
| 438 | + let replacement = ""; | |
| 439 | + if (item.type === "path") { | |
| 440 | + replacement = `@${item.path} `; | |
| 441 | + } else if (item.type === "skill") { | |
| 442 | + replacement = `$${item.skill.name} `; | |
| 443 | + } else if (item.type === "slash-command") { | |
| 444 | + replacement = `/${item.command} `; | |
| 445 | + } else if (item.type === "provider-slash-command") { | |
| 446 | + replacement = `/${item.command.name} `; | |
| 447 | + } | |
| 448 | + | |
| 449 | + const result = replaceTextRange( | |
| 450 | + draftMessage, | |
| 451 | + composerTrigger.rangeStart, | |
| 452 | + composerTrigger.rangeEnd, | |
| 453 | + replacement, | |
| 454 | + ); | |
| 455 | + setCursorPosition(result.cursor); | |
| 456 | + onChangeDraftMessage(result.text); | |
| 457 | + }, | |
| 458 | + [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], | |
| 459 | + ); | |
| 460 | + | |
| 170 | 461 | // ── Model menu ─────────────────────────────────────────── |
| 171 | 462 | const providerGroups = useMemo(() => { |
| 172 | 463 | const options = buildModelOptions(props.serverConfig, currentModelSelection); |
| @@ -346,8 +637,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 173 unmodified lines | ||
| 346 | 637 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", |
| 347 | 638 | }} |
| 348 | 639 | > |
| 349 | − <View className="w-full"> | |
| 350 | − {/* Input surface — the pill/card IS the glass element */} | |
| 640 | + <View className="w-full" style={{ position: "relative" }}> | |
| 641 | + {composerTrigger && composerMenuItems.length > 0 ? ( | |
| 642 | + <View | |
| 643 | + style={{ | |
| 644 | + position: "absolute", | |
| 645 | + bottom: "100%", | |
| 646 | + left: 0, | |
| 647 | + right: 0, | |
| 648 | + marginBottom: 8, | |
| 649 | + zIndex: 10, | |
| 650 | + }} | |
| 651 | + > | |
| 652 | + <ComposerCommandPopover | |
| 653 | + items={composerMenuItems} | |
| 654 | + triggerKind={composerTrigger.kind} | |
| 655 | + isLoading={pathSearchLoading} | |
| 656 | + onSelect={handleCommandSelect} | |
| 657 | + /> | |
| 658 | + </View> | |
| 659 | + ) : null} | |
| 660 | + | |
| 351 | 661 | <ComposerSurface |
| 352 | 662 | isDarkMode={isDarkMode} |
| 353 | 663 | style={ |
| @@ -387,6 +697,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines | ||
| 387 | 697 | multiline |
| 388 | 698 | value={props.draftMessage} |
| 389 | 699 | onChangeText={props.onChangeDraftMessage} |
| 700 | + onSelectionChange={handleSelectionChange} | |
| 390 | 701 | placeholder={props.placeholder} |
| 391 | 702 | placeholderTextColor={placeholderColor} |
| 392 | 703 | editable={props.connectionState === "ready"} |
| @@ -463,7 +774,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 70 unmodified lines | ||
| 463 | 774 | icon="arrow.up" |
| 464 | 775 | variant="primary" |
| 465 | 776 | disabled={!canSend} |
| 466 | − onPress={props.onSendMessage} | |
| 777 | + onPress={handleSend} | |
| 467 | 778 | /> |
| 468 | 779 | ) |
| 469 | 780 | ) : null} |
| @@ -508,7 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 38 unmodified lines | ||
| 508 | 819 | label={sendLabel} |
| 509 | 820 | variant="primary" |
| 510 | 821 | disabled={!canSend} |
| 511 | − onPress={props.onSendMessage} | |
| 822 | + onPress={handleSend} | |
| 512 | 823 | /> |
| 513 | 824 | </View> |
| 514 | 825 | ) : null} |
| @@ -53,6 +53,7 @@ export interface ThreadDetailScreenProps { · 52 unmodified lines | ||
| 53 | 53 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 54 | 54 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; |
| 55 | 55 | readonly activeThreadBusy: boolean; |
| 56 | + readonly environmentId: string; | |
| 56 | 57 | readonly projectWorkspaceRoot: string | null; |
| 57 | 58 | readonly selectedThreadQueueCount: number; |
| 58 | 59 | readonly serverConfig: T3ServerConfig | null; |
| @@ -313,6 +314,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 254 unmodified lines | ||
| 313 | 314 | serverConfig={props.serverConfig} |
| 314 | 315 | queueCount={props.selectedThreadQueueCount} |
| 315 | 316 | activeThreadBusy={props.activeThreadBusy} |
| 317 | + environmentId={props.environmentId} | |
| 318 | + projectCwd={props.projectWorkspaceRoot} | |
| 316 | 319 | bottomInset={composerBottomInset} |
| 317 | 320 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 318 | 321 | onPickDraftImages={props.onPickDraftImages} |
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | −import { useCallback, useState } from "react"; | |
| 2 | +import { useCallback, useMemo, useState } from "react"; | |
| 3 | 3 | import * as Arr from "effect/Array"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import { pipe } from "effect/Function"; |
| 6 | 6 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; |
| 7 | 7 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 8 | import { useGitStatus, gitStatusManager } from "../../state/use-git-status"; |
| 9 | +import { dismissGitActionResult, useGitActionProgress } from "../../state/use-git-action-state"; | |
| 9 | 10 | |
| 10 | 11 | import { EmptyState } from "../../components/EmptyState"; |
| 11 | 12 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| @@ -20,10 +21,10 @@ import { · 8 unmodified lines | ||
| 20 | 21 | } from "../../state/use-remote-environment-registry"; |
| 21 | 22 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 22 | 23 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 24 | +import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; | |
| 23 | 25 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 24 | 26 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 25 | 27 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 26 | −import { screenTitle } from "./threadPresentation"; | |
| 27 | 28 | import { useSelectedThreadCommands } from "./use-selected-thread-commands"; |
| 28 | 29 | import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; |
| 29 | 30 | import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; |
| @@ -82,6 +83,20 @@ export function ThreadRouteScreen() { · 52 unmodified lines | ||
| 82 | 83 | cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, |
| 83 | 84 | }); |
| 84 | 85 | |
| 86 | + /* ─── Git action progress (for overlay banner) ──────────────────── */ | |
| 87 | + const gitActionProgressTarget = useMemo( | |
| 88 | + () => ({ | |
| 89 | + environmentId: selectedThread?.environmentId ?? null, | |
| 90 | + cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | |
| 91 | + }), | |
| 92 | + [ | |
| 93 | + selectedThread?.environmentId, | |
| 94 | + selectedThread?.worktreePath, | |
| 95 | + selectedThreadProject?.workspaceRoot, | |
| 96 | + ], | |
| 97 | + ); | |
| 98 | + const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | |
| 99 | + | |
| 85 | 100 | const handleRefreshGitStatus = useCallback(async () => { |
| 86 | 101 | if (!selectedThread) return; |
| 87 | 102 | await gitStatusManager.refresh({ |
| @@ -152,7 +167,7 @@ export function ThreadRouteScreen() { · 64 unmodified lines | ||
| 152 | 167 | ); |
| 153 | 168 | |
| 154 | 169 | const headerSubtitle = [ |
| 155 | − screenTitle(serverConfig, null), | |
| 170 | + selectedThreadProject?.title ?? null, | |
| 156 | 171 | selectedEnvironmentConnection?.environmentLabel ?? null, |
| 157 | 172 | ] |
| 158 | 173 | .filter(Boolean) |
| @@ -212,6 +227,8 @@ export function ThreadRouteScreen() { · 53 unmodified lines | ||
| 212 | 227 | onRunAction={gitActions.onRunSelectedThreadGitAction} |
| 213 | 228 | /> |
| 214 | 229 | |
| 230 | + <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> | |
| 231 | + | |
| 215 | 232 | <View className="flex-1 bg-screen"> |
| 216 | 233 | <ThreadDetailScreen |
| 217 | 234 | selectedThread={selectedThreadDetail} |
| @@ -231,6 +248,7 @@ export function ThreadRouteScreen() { · 13 unmodified lines | ||
| 231 | 248 | draftAttachments={composer.draftAttachments} |
| 232 | 249 | connectionStateLabel={routeConnectionState} |
| 233 | 250 | activeThreadBusy={composer.activeThreadBusy} |
| 251 | + environmentId={selectedThread.environmentId} | |
| 234 | 252 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} |
| 235 | 253 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 236 | 254 | onOpenDrawer={handleOpenDrawer} |
| @@ -12,7 +12,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 11 unmodified lines | ||
| 12 | 12 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 13 | 13 | |
| 14 | 14 | import { AppText as Text } from "../../../components/AppText"; |
| 15 | −import { StatusPill } from "../../../components/StatusPill"; | |
| 16 | 15 | import { useGitStatus } from "../../../state/use-git-status"; |
| 17 | 16 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 18 | 17 | import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions"; |
| @@ -231,17 +230,6 @@ export function GitOverviewSheet() { · 212 unmodified lines | ||
| 231 | 230 | </View> |
| 232 | 231 | |
| 233 | 232 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} |
| 234 | − | |
| 235 | − {gitOperationLabel ? ( | |
| 236 | − <View className="self-start"> | |
| 237 | − <StatusPill | |
| 238 | − label={gitOperationLabel} | |
| 239 | − pillClassName="bg-neutral-900/8" | |
| 240 | − textClassName="text-neutral-700" | |
| 241 | − size="compact" | |
| 242 | − /> | |
| 243 | − </View> | |
| 244 | − ) : null} | |
| 245 | 233 | </ScrollView> |
| 246 | 234 | </View> |
| 247 | 235 | ); |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | |
| 2 | 1 | import type { StatusTone } from "../../components/StatusPill"; |
| 3 | 2 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; |
| 4 | 3 | |
| @@ -52,12 +51,3 @@ export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string · 47 unmodified lines | ||
| 52 | 51 | const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); |
| 53 | 52 | return url.toString(); |
| 54 | 53 | } |
| 55 | − | |
| 56 | −export function screenTitle(config: T3ServerConfig | null, serverUrl: string | null): string { | |
| 57 | − if (config) { | |
| 58 | − const segments = config.cwd.split(/[/\\]/).filter(Boolean); | |
| 59 | − const candidate = segments.at(-1) ?? config.cwd; | |
| 60 | − return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate; | |
| 61 | − } | |
| 62 | − return serverUrl ?? "T3 Code"; | |
| 63 | −} | |
| @@ -96,77 +96,53 @@ export function useProjectActions() { · 95 unmodified lines | ||
| 96 | 96 | const initialMessageText = input.initialMessageText.trim(); |
| 97 | 97 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); |
| 98 | 98 | |
| 99 | − if (input.envMode === "worktree") { | |
| 100 | − if (!input.branch || initialMessageText.length === 0) { | |
| 101 | − return null; | |
| 102 | − } | |
| 99 | + if (initialMessageText.length === 0) { | |
| 100 | + return null; | |
| 101 | + } | |
| 102 | + if (input.envMode === "worktree" && !input.branch) { | |
| 103 | + return null; | |
| 104 | + } | |
| 103 | 105 | |
| 104 | − await client.orchestration.dispatchCommand({ | |
| 105 | − type: "thread.turn.start", | |
| 106 | − commandId: CommandId.make(uuidv4()), | |
| 107 | − threadId, | |
| 108 | − message: { | |
| 109 | − messageId: MessageId.make(uuidv4()), | |
| 110 | − role: "user", | |
| 111 | − text: initialMessageText, | |
| 112 | − attachments: input.initialAttachments, | |
| 113 | − }, | |
| 114 | − modelSelection: input.modelSelection, | |
| 115 | − titleSeed: nextTitle, | |
| 116 | − runtimeMode: input.runtimeMode, | |
| 117 | − interactionMode: input.interactionMode, | |
| 118 | − bootstrap: { | |
| 119 | − createThread: { | |
| 120 | − projectId: input.project.id, | |
| 121 | − title: nextTitle, | |
| 122 | − modelSelection: input.modelSelection, | |
| 123 | − runtimeMode: input.runtimeMode, | |
| 124 | − interactionMode: input.interactionMode, | |
| 125 | − branch: input.branch, | |
| 126 | − worktreePath: null, | |
| 127 | − createdAt, | |
| 128 | − }, | |
| 129 | − prepareWorktree: { | |
| 130 | − projectCwd: input.project.workspaceRoot, | |
| 131 | − baseBranch: input.branch, | |
| 132 | − branch: buildTemporaryWorktreeBranchName(), | |
| 133 | − }, | |
| 134 | − runSetupScript: true, | |
| 135 | − }, | |
| 136 | − createdAt: new Date().toISOString(), | |
| 137 | − }); | |
| 138 | − } else { | |
| 139 | − await client.orchestration.dispatchCommand({ | |
| 140 | − type: "thread.create", | |
| 141 | − commandId: CommandId.make(uuidv4()), | |
| 142 | − threadId, | |
| 143 | − projectId: input.project.id, | |
| 144 | − title: nextTitle, | |
| 145 | − modelSelection: input.modelSelection, | |
| 146 | − runtimeMode: input.runtimeMode, | |
| 147 | − interactionMode: input.interactionMode, | |
| 148 | − branch: input.branch, | |
| 149 | − worktreePath: input.worktreePath, | |
| 150 | − createdAt, | |
| 151 | − }); | |
| 106 | + const isWorktree = input.envMode === "worktree"; | |
| 152 | 107 | |
| 153 | − if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { | |
| 154 | − await client.orchestration.dispatchCommand({ | |
| 155 | − type: "thread.turn.start", | |
| 156 | − commandId: CommandId.make(uuidv4()), | |
| 157 | − threadId, | |
| 158 | − message: { | |
| 159 | − messageId: MessageId.make(uuidv4()), | |
| 160 | − role: "user", | |
| 161 | − text: initialMessageText, | |
| 162 | − attachments: input.initialAttachments, | |
| 163 | − }, | |
| 108 | + await client.orchestration.dispatchCommand({ | |
| 109 | + type: "thread.turn.start", | |
| 110 | + commandId: CommandId.make(uuidv4()), | |
| 111 | + threadId, | |
| 112 | + message: { | |
| 113 | + messageId: MessageId.make(uuidv4()), | |
| 114 | + role: "user", | |
| 115 | + text: initialMessageText, | |
| 116 | + attachments: input.initialAttachments, | |
| 117 | + }, | |
| 118 | + modelSelection: input.modelSelection, | |
| 119 | + titleSeed: nextTitle, | |
| 120 | + runtimeMode: input.runtimeMode, | |
| 121 | + interactionMode: input.interactionMode, | |
| 122 | + bootstrap: { | |
| 123 | + createThread: { | |
| 124 | + projectId: input.project.id, | |
| 125 | + title: nextTitle, | |
| 126 | + modelSelection: input.modelSelection, | |
| 164 | 127 | runtimeMode: input.runtimeMode, |
| 165 | 128 | interactionMode: input.interactionMode, |
| 166 | − createdAt: new Date().toISOString(), | |
| 167 | − }); | |
| 168 | − } | |
| 169 | − } | |
| 129 | + branch: input.branch, | |
| 130 | + worktreePath: isWorktree ? null : input.worktreePath, | |
| 131 | + createdAt, | |
| 132 | + }, | |
| 133 | + ...(isWorktree | |
| 134 | + ? { | |
| 135 | + prepareWorktree: { | |
| 136 | + projectCwd: input.project.workspaceRoot, | |
| 137 | + baseBranch: input.branch!, | |
| 138 | + branch: buildTemporaryWorktreeBranchName(), | |
| 139 | + }, | |
| 140 | + runSetupScript: true, | |
| 141 | + } | |
| 142 | + : {}), | |
| 143 | + }, | |
| 144 | + createdAt: new Date().toISOString(), | |
| 145 | + }); | |
| 170 | 146 | |
| 171 | 147 | await refreshRemoteData([input.project.environmentId]); |
| 172 | 148 | return { |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | getEnvironmentClient, |
| 22 | 22 | setPendingConnectionError, |
| 23 | 23 | } from "../../state/use-remote-environment-registry"; |
| 24 | −import { gitActionManager } from "../../state/use-git-action-state"; | |
| 24 | +import { gitActionManager, showGitActionResult } from "../../state/use-git-action-state"; | |
| 25 | 25 | import { gitBranchManager } from "../../state/use-git-branches"; |
| 26 | 26 | import { gitStatusManager } from "../../state/use-git-status"; |
| 27 | 27 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| @@ -122,7 +122,9 @@ export function useSelectedThreadGitActions() { · 94 unmodified lines | ||
| 122 | 122 | cwd, |
| 123 | 123 | }); |
| 124 | 124 | } catch (error) { |
| 125 | − setPendingConnectionError(error instanceof Error ? error.message : "Git action failed."); | |
| 125 | + const message = error instanceof Error ? error.message : "Git action failed."; | |
| 126 | + setPendingConnectionError(message); | |
| 127 | + showGitActionResult({ type: "error", title: "Git action failed", description: message }); | |
| 126 | 128 | return null; |
| 127 | 129 | } |
| 128 | 130 | }, |
| @@ -264,8 +266,17 @@ export function useSelectedThreadGitActions() { · 135 unmodified lines | ||
| 264 | 266 | |
| 265 | 267 | const onPullSelectedThreadBranch = useCallback(async () => { |
| 266 | 268 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { |
| 267 | − await gitActionManager.pull({ environmentId: thread.environmentId, cwd }); | |
| 269 | + const result = await gitActionManager.pull({ environmentId: thread.environmentId, cwd }); | |
| 268 | 270 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); |
| 271 | + if (result) { | |
| 272 | + showGitActionResult({ | |
| 273 | + type: "success", | |
| 274 | + title: | |
| 275 | + result.status === "skipped_up_to_date" | |
| 276 | + ? "Already up to date" | |
| 277 | + : `Pulled latest on ${result.branch}`, | |
| 278 | + }); | |
| 279 | + } | |
| 269 | 280 | }); |
| 270 | 281 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); |
| 271 | 282 | |
| @@ -292,6 +303,13 @@ export function useSelectedThreadGitActions() { · 20 unmodified lines | ||
| 292 | 303 | return null; |
| 293 | 304 | } |
| 294 | 305 | |
| 306 | + showGitActionResult({ | |
| 307 | + type: "success", | |
| 308 | + title: result.toast.title, | |
| 309 | + description: result.toast.description, | |
| 310 | + prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, | |
| 311 | + }); | |
| 312 | + | |
| 295 | 313 | if (result.branch.status === "created" && result.branch.name) { |
| 296 | 314 | await syncSelectedThreadBranchState({ |
| 297 | 315 | thread, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | getGitActionTargetKey, |
| 9 | 9 | gitActionStateAtom, |
| 10 | 10 | } from "@t3tools/client-runtime"; |
| 11 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 11 | 12 | |
| 12 | 13 | import { uuidv4 } from "../lib/uuid"; |
| 13 | 14 | import { appAtomRegistry } from "./atom-registry"; |
| @@ -29,3 +30,133 @@ export function useGitActionState(target: GitActionTarget): GitActionState { · 15 unmodified lines | ||
| 29 | 30 | ); |
| 30 | 31 | return targetKey === null ? EMPTY_GIT_ACTION_STATE : state; |
| 31 | 32 | } |
| 33 | + | |
| 34 | +// --------------------------------------------------------------------------- | |
| 35 | +// Git action result notification | |
| 36 | +// --------------------------------------------------------------------------- | |
| 37 | + | |
| 38 | +export interface GitActionResultNotification { | |
| 39 | + readonly type: "success" | "error"; | |
| 40 | + readonly title: string; | |
| 41 | + readonly description?: string; | |
| 42 | + readonly prUrl?: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +const RESULT_DISMISS_MS = 5_000; | |
| 46 | + | |
| 47 | +type ResultListener = (result: GitActionResultNotification | null) => void; | |
| 48 | +const resultListeners = new Set<ResultListener>(); | |
| 49 | +let currentResult: GitActionResultNotification | null = null; | |
| 50 | +let dismissTimer: ReturnType<typeof setTimeout> | null = null; | |
| 51 | + | |
| 52 | +function broadcast(result: GitActionResultNotification | null): void { | |
| 53 | + currentResult = result; | |
| 54 | + for (const listener of resultListeners) { | |
| 55 | + listener(result); | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function showGitActionResult(result: GitActionResultNotification): void { | |
| 60 | + if (dismissTimer) clearTimeout(dismissTimer); | |
| 61 | + broadcast(result); | |
| 62 | + dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function dismissGitActionResult(): void { | |
| 66 | + if (dismissTimer) clearTimeout(dismissTimer); | |
| 67 | + broadcast(null); | |
| 68 | +} | |
| 69 | + | |
| 70 | +export function useGitActionResultNotification(): { | |
| 71 | + readonly result: GitActionResultNotification | null; | |
| 72 | + readonly dismiss: () => void; | |
| 73 | +} { | |
| 74 | + const [result, setResult] = useState<GitActionResultNotification | null>(currentResult); | |
| 75 | + | |
| 76 | + useEffect(() => { | |
| 77 | + resultListeners.add(setResult); | |
| 78 | + setResult(currentResult); | |
| 79 | + return () => { | |
| 80 | + resultListeners.delete(setResult); | |
| 81 | + }; | |
| 82 | + }, []); | |
| 83 | + | |
| 84 | + return { result, dismiss: dismissGitActionResult }; | |
| 85 | +} | |
| 86 | + | |
| 87 | +// --------------------------------------------------------------------------- | |
| 88 | +// Unified git action progress (combines running state + result notification) | |
| 89 | +// --------------------------------------------------------------------------- | |
| 90 | + | |
| 91 | +export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; | |
| 92 | + | |
| 93 | +export interface GitActionProgress { | |
| 94 | + readonly phase: GitActionProgressPhase; | |
| 95 | + readonly label: string | null; | |
| 96 | + readonly description: string | null; | |
| 97 | + readonly prUrl?: string; | |
| 98 | +} | |
| 99 | + | |
| 100 | +const EMPTY_PROGRESS: GitActionProgress = { | |
| 101 | + phase: "idle", | |
| 102 | + label: null, | |
| 103 | + description: null, | |
| 104 | +}; | |
| 105 | + | |
| 106 | +function formatElapsedSeconds(ms: number | null): string | null { | |
| 107 | + if (ms === null) return null; | |
| 108 | + const elapsed = Math.max(0, Math.floor((Date.now() - ms) / 1000)); | |
| 109 | + if (elapsed < 2) return null; | |
| 110 | + return `Running for ${elapsed}s`; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export function useGitActionProgress(target: GitActionTarget): GitActionProgress { | |
| 114 | + const actionState = useGitActionState(target); | |
| 115 | + const { result } = useGitActionResultNotification(); | |
| 116 | + | |
| 117 | + const [, forceUpdate] = useState(0); | |
| 118 | + const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| 119 | + | |
| 120 | + const startElapsedTimer = useCallback(() => { | |
| 121 | + if (intervalRef.current) return; | |
| 122 | + intervalRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000); | |
| 123 | + }, []); | |
| 124 | + | |
| 125 | + const stopElapsedTimer = useCallback(() => { | |
| 126 | + if (intervalRef.current) { | |
| 127 | + clearInterval(intervalRef.current); | |
| 128 | + intervalRef.current = null; | |
| 129 | + } | |
| 130 | + }, []); | |
| 131 | + | |
| 132 | + useEffect(() => { | |
| 133 | + if (actionState.isRunning) { | |
| 134 | + startElapsedTimer(); | |
| 135 | + } else { | |
| 136 | + stopElapsedTimer(); | |
| 137 | + } | |
| 138 | + return stopElapsedTimer; | |
| 139 | + }, [actionState.isRunning, startElapsedTimer, stopElapsedTimer]); | |
| 140 | + | |
| 141 | + if (actionState.isRunning) { | |
| 142 | + const description = | |
| 143 | + actionState.lastOutputLine ?? | |
| 144 | + formatElapsedSeconds(actionState.hookStartedAtMs ?? actionState.phaseStartedAtMs); | |
| 145 | + return { | |
| 146 | + phase: "running", | |
| 147 | + label: actionState.currentLabel, | |
| 148 | + description, | |
| 149 | + }; | |
| 150 | + } | |
| 151 | + | |
| 152 | + if (result) { | |
| 153 | + return { | |
| 154 | + phase: result.type, | |
| 155 | + label: result.title, | |
| 156 | + description: result.description ?? null, | |
| 157 | + prUrl: result.prUrl, | |
| 158 | + }; | |
| 159 | + } | |
| 160 | + | |
| 161 | + return EMPTY_PROGRESS; | |
| 162 | +} | |
| @@ -1,16 +1,18 @@ | ||
| 1 | +import { | |
| 2 | + detectComposerTrigger as detectComposerTriggerBase, | |
| 3 | + type ComposerTrigger, | |
| 4 | + type ComposerTriggerKind, | |
| 5 | + type ComposerSlashCommand, | |
| 6 | +} from "@t3tools/shared/composerTrigger"; | |
| 7 | +export { | |
| 8 | + replaceTextRange, | |
| 9 | + parseStandaloneComposerSlashCommand, | |
| 10 | +} from "@t3tools/shared/composerTrigger"; | |
| 11 | +export type { ComposerTrigger, ComposerTriggerKind, ComposerSlashCommand }; | |
| 12 | + | |
| 1 | 13 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 14 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 15 | |
| 4 | −export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | |
| 5 | −export type ComposerSlashCommand = "model" | "plan" | "default"; | |
| 6 | − | |
| 7 | −export interface ComposerTrigger { | |
| 8 | − kind: ComposerTriggerKind; | |
| 9 | − query: string; | |
| 10 | − rangeStart: number; | |
| 11 | − rangeEnd: number; | |
| 12 | −} | |
| 13 | − | |
| 14 | 16 | const isInlineTokenSegment = ( |
| 15 | 17 | segment: |
| 16 | 18 | | { type: "text"; text: string } |
| @@ -216,83 +218,5 @@ export function isCollapsedCursorAdjacentToInlineToken( · 199 unmodified lines | ||
| 216 | 218 | export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; |
| 217 | 219 | |
| 218 | 220 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 219 | − const cursor = clampCursor(text, cursorInput); | |
| 220 | − const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | |
| 221 | − const linePrefix = text.slice(lineStart, cursor); | |
| 222 | − | |
| 223 | − if (linePrefix.startsWith("/")) { | |
| 224 | − const commandMatch = /^\/(\S*)$/.exec(linePrefix); | |
| 225 | − if (commandMatch) { | |
| 226 | − const commandQuery = commandMatch[1] ?? ""; | |
| 227 | − if (commandQuery.toLowerCase() === "model") { | |
| 228 | − return { | |
| 229 | − kind: "slash-model", | |
| 230 | − query: "", | |
| 231 | − rangeStart: lineStart, | |
| 232 | − rangeEnd: cursor, | |
| 233 | − }; | |
| 234 | − } | |
| 235 | − return { | |
| 236 | − kind: "slash-command", | |
| 237 | − query: commandQuery, | |
| 238 | − rangeStart: lineStart, | |
| 239 | − rangeEnd: cursor, | |
| 240 | − }; | |
| 241 | − } | |
| 242 | − | |
| 243 | − const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | |
| 244 | − if (modelMatch) { | |
| 245 | − return { | |
| 246 | − kind: "slash-model", | |
| 247 | − query: (modelMatch[1] ?? "").trim(), | |
| 248 | − rangeStart: lineStart, | |
| 249 | − rangeEnd: cursor, | |
| 250 | − }; | |
| 251 | − } | |
| 252 | − } | |
| 253 | − | |
| 254 | − const tokenStart = tokenStartForCursor(text, cursor); | |
| 255 | − const token = text.slice(tokenStart, cursor); | |
| 256 | − if (token.startsWith("$")) { | |
| 257 | − return { | |
| 258 | − kind: "skill", | |
| 259 | − query: token.slice(1), | |
| 260 | − rangeStart: tokenStart, | |
| 261 | − rangeEnd: cursor, | |
| 262 | − }; | |
| 263 | − } | |
| 264 | − if (!token.startsWith("@")) { | |
| 265 | − return null; | |
| 266 | − } | |
| 267 | − | |
| 268 | − return { | |
| 269 | − kind: "path", | |
| 270 | − query: token.slice(1), | |
| 271 | − rangeStart: tokenStart, | |
| 272 | − rangeEnd: cursor, | |
| 273 | − }; | |
| 274 | −} | |
| 275 | − | |
| 276 | −export function parseStandaloneComposerSlashCommand( | |
| 277 | − text: string, | |
| 278 | −): Exclude<ComposerSlashCommand, "model"> | null { | |
| 279 | − const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | |
| 280 | − if (!match) { | |
| 281 | − return null; | |
| 282 | − } | |
| 283 | − const command = match[1]?.toLowerCase(); | |
| 284 | − if (command === "plan") return "plan"; | |
| 285 | − return "default"; | |
| 286 | −} | |
| 287 | − | |
| 288 | −export function replaceTextRange( | |
| 289 | − text: string, | |
| 290 | − rangeStart: number, | |
| 291 | − rangeEnd: number, | |
| 292 | − replacement: string, | |
| 293 | −): { text: string; cursor: number } { | |
| 294 | − const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | |
| 295 | − const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | |
| 296 | − const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | |
| 297 | − return { text: nextText, cursor: safeStart + replacement.length }; | |
| 221 | + return detectComposerTriggerBase(text, cursorInput, isWhitespace); | |
| 298 | 222 | } |
| @@ -67,6 +67,10 @@ · 66 unmodified lines | ||
| 67 | 67 | "./qrCode": { |
| 68 | 68 | "types": "./src/qrCode.ts", |
| 69 | 69 | "import": "./src/qrCode.ts" |
| 70 | + }, | |
| 71 | + "./composerTrigger": { | |
| 72 | + "types": "./src/composerTrigger.ts", | |
| 73 | + "import": "./src/composerTrigger.ts" | |
| 70 | 74 | } |
| 71 | 75 | }, |
| 72 | 76 | "scripts": { |
| @@ -0,0 +1,117 @@ | ||
| 1 | +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | |
| 2 | +export type ComposerSlashCommand = "model" | "plan" | "default"; | |
| 3 | + | |
| 4 | +export interface ComposerTrigger { | |
| 5 | + kind: ComposerTriggerKind; | |
| 6 | + query: string; | |
| 7 | + rangeStart: number; | |
| 8 | + rangeEnd: number; | |
| 9 | +} | |
| 10 | + | |
| 11 | +function clampCursor(text: string, cursor: number): number { | |
| 12 | + if (!Number.isFinite(cursor)) return text.length; | |
| 13 | + return Math.max(0, Math.min(text.length, Math.floor(cursor))); | |
| 14 | +} | |
| 15 | + | |
| 16 | +function isWhitespace(char: string): boolean { | |
| 17 | + return char === " " || char === "\n" || char === "\t" || char === "\r"; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Detect an active trigger (@path, $skill, /command) at the cursor position. | |
| 22 | + * | |
| 23 | + * Accepts an optional `isWhitespaceChar` override so callers with inline | |
| 24 | + * placeholder characters (e.g. terminal context chips on web) can treat | |
| 25 | + * those as token boundaries. | |
| 26 | + */ | |
| 27 | +export function detectComposerTrigger( | |
| 28 | + text: string, | |
| 29 | + cursorInput: number, | |
| 30 | + isWhitespaceChar?: (char: string) => boolean, | |
| 31 | +): ComposerTrigger | null { | |
| 32 | + const cursor = clampCursor(text, cursorInput); | |
| 33 | + const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | |
| 34 | + const linePrefix = text.slice(lineStart, cursor); | |
| 35 | + | |
| 36 | + if (linePrefix.startsWith("/")) { | |
| 37 | + const commandMatch = /^\/(\S*)$/.exec(linePrefix); | |
| 38 | + if (commandMatch) { | |
| 39 | + const commandQuery = commandMatch[1] ?? ""; | |
| 40 | + if (commandQuery.toLowerCase() === "model") { | |
| 41 | + return { | |
| 42 | + kind: "slash-model", | |
| 43 | + query: "", | |
| 44 | + rangeStart: lineStart, | |
| 45 | + rangeEnd: cursor, | |
| 46 | + }; | |
| 47 | + } | |
| 48 | + return { | |
| 49 | + kind: "slash-command", | |
| 50 | + query: commandQuery, | |
| 51 | + rangeStart: lineStart, | |
| 52 | + rangeEnd: cursor, | |
| 53 | + }; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | |
| 57 | + if (modelMatch) { | |
| 58 | + return { | |
| 59 | + kind: "slash-model", | |
| 60 | + query: (modelMatch[1] ?? "").trim(), | |
| 61 | + rangeStart: lineStart, | |
| 62 | + rangeEnd: cursor, | |
| 63 | + }; | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + const wsCheck = isWhitespaceChar ?? isWhitespace; | |
| 68 | + let tokenIdx = cursor - 1; | |
| 69 | + while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) { | |
| 70 | + tokenIdx -= 1; | |
| 71 | + } | |
| 72 | + const tokenStart = tokenIdx + 1; | |
| 73 | + | |
| 74 | + const token = text.slice(tokenStart, cursor); | |
| 75 | + if (token.startsWith("$")) { | |
| 76 | + return { | |
| 77 | + kind: "skill", | |
| 78 | + query: token.slice(1), | |
| 79 | + rangeStart: tokenStart, | |
| 80 | + rangeEnd: cursor, | |
| 81 | + }; | |
| 82 | + } | |
| 83 | + if (!token.startsWith("@")) { | |
| 84 | + return null; | |
| 85 | + } | |
| 86 | + | |
| 87 | + return { | |
| 88 | + kind: "path", | |
| 89 | + query: token.slice(1), | |
| 90 | + rangeStart: tokenStart, | |
| 91 | + rangeEnd: cursor, | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function parseStandaloneComposerSlashCommand( | |
| 96 | + text: string, | |
| 97 | +): Exclude<ComposerSlashCommand, "model"> | null { | |
| 98 | + const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | |
| 99 | + if (!match) { | |
| 100 | + return null; | |
| 101 | + } | |
| 102 | + const command = match[1]?.toLowerCase(); | |
| 103 | + if (command === "plan") return "plan"; | |
| 104 | + return "default"; | |
| 105 | +} | |
| 106 | + | |
| 107 | +export function replaceTextRange( | |
| 108 | + text: string, | |
| 109 | + rangeStart: number, | |
| 110 | + rangeEnd: number, | |
| 111 | + replacement: string, | |
| 112 | +): { text: string; cursor: number } { | |
| 113 | + const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | |
| 114 | + const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | |
| 115 | + const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | |
| 116 | + return { text: nextText, cursor: safeStart + replacement.length }; | |
| 117 | +} | |