History

Add composer suggestions and git action overlay

- Suggest files, skills, and commands from composer triggers - Show git action progress, success, and error feedback

Julius Marminge committed 7 months ago

Commit fbcf04a1 parent 99190d713 files · +1000 −188Browse files

Unified · Split

13 files
  1. apps/mobile/src/features/threads/ComposerCommandPopover.tsx +211 −0
  2. apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +121 −0
  3. apps/mobile/src/features/threads/ThreadComposer.tsx +315 −4
  4. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +3 −0
  5. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +21 −3
  6. apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +0 −12
  7. apps/mobile/src/features/threads/threadPresentation.ts +0 −10
  8. apps/mobile/src/features/threads/use-project-actions.ts +43 −67
  9. apps/mobile/src/features/threads/use-selected-thread-git-actions.ts +21 −3
  10. apps/mobile/src/state/use-git-action-state.ts +131 −0
  11. apps/web/src/composer-logic.ts +13 −89
  12. packages/shared/package.json +4 −0
  13. packages/shared/src/composerTrigger.ts +117 −0
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+211−0
@@ -0,0 +1,211 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
3import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
4import { SymbolView } from "expo-symbols";
5import { memo } from "react";
6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
7
8import { AppText as Text } from "../../components/AppText";
9
10export 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
41interface ComposerCommandPopoverProps {
42 readonly items: ReadonlyArray<ComposerCommandItem>;
43 readonly triggerKind: ComposerTriggerKind | null;
44 readonly isLoading: boolean;
45 readonly onSelect: (item: ComposerCommandItem) => void;
46}
47
48function 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
89function 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
101function 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
114function 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
130const 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
169export 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});
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+121−0
@@ -0,0 +1,121 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";
3import { useCallback, useEffect, useRef } from "react";
4import { ActivityIndicator, Linking, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { AppText as Text } from "../../components/AppText";
9import { useThemeColor } from "../../lib/useThemeColor";
10import type { GitActionProgress } from "../../state/use-git-action-state";
11
12export 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
59function 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
94function 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}
apps/mobile/src/features/threads/ThreadComposer.tsx+315−4
@@ -8,6 +8,11 @@ import type { · 7 unmodified lines
8 ServerConfig as T3ServerConfig,8 ServerConfig as T3ServerConfig,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";10import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";
11import {
12 detectComposerTrigger,
13 replaceTextRange,
14 type ComposerTrigger,
15} from "@t3tools/shared/composerTrigger";
11import { TextInputWrapper } from "expo-paste-input";16import { TextInputWrapper } from "expo-paste-input";
12import type { ReactNode } from "react";17import type { ReactNode } from "react";
13import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";18import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -17,6 +22,8 @@ import { · 3 unmodified lines
17 TextInput as RNTextInput,22 TextInput as RNTextInput,
18 useColorScheme,23 useColorScheme,
19 View,24 View,
25 type NativeSyntheticEvent,
26 type TextInputSelectionChangeEventData,
20 type ViewStyle,27 type ViewStyle,
21} from "react-native";28} from "react-native";
22import ImageViewing from "react-native-image-viewing";29import ImageViewing from "react-native-image-viewing";
@@ -30,6 +37,13 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 7 unmodified lines
30import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";37import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
31import type { RemoteClientConnectionState } from "../../lib/connection";38import type { RemoteClientConnectionState } from "../../lib/connection";
32import { useNativePaste } from "../../hooks/useNativePaste";39import { useNativePaste } from "../../hooks/useNativePaste";
40import {
41 insertRankedSearchResult,
42 normalizeSearchQuery,
43 scoreQueryMatch,
44} from "@t3tools/shared/searchRanking";
45import { getEnvironmentClient } from "../../state/use-remote-environment-registry";
46import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
33 47
34/**48/**
35 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).49 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
@@ -53,6 +67,8 @@ export interface ThreadComposerProps { · 17 unmodified lines
53 readonly serverConfig: T3ServerConfig | null;67 readonly serverConfig: T3ServerConfig | null;
54 readonly queueCount: number;68 readonly queueCount: number;
55 readonly activeThreadBusy: boolean;69 readonly activeThreadBusy: boolean;
70 readonly environmentId: string;
71 readonly projectCwd: string | null;
56 readonly onChangeDraftMessage: (value: string) => void;72 readonly onChangeDraftMessage: (value: string) => void;
57 readonly onPickDraftImages: () => Promise<void>;73 readonly onPickDraftImages: () => Promise<void>;
58 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;74 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
@@ -167,6 +183,281 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 108 unmodified lines
167 void props.onNativePasteImages(uris);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 // ── Model menu ───────────────────────────────────────────461 // ── Model menu ───────────────────────────────────────────
171 const providerGroups = useMemo(() => {462 const providerGroups = useMemo(() => {
172 const options = buildModelOptions(props.serverConfig, currentModelSelection);463 const options = buildModelOptions(props.serverConfig, currentModelSelection);
@@ -346,8 +637,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 173 unmodified lines
346 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",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">640 <View className="w-full" style={{ position: "relative" }}>
350 {/* Input surface — the pill/card IS the glass element */}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 <ComposerSurface661 <ComposerSurface
352 isDarkMode={isDarkMode}662 isDarkMode={isDarkMode}
353 style={663 style={
@@ -387,6 +697,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines
387 multiline697 multiline
388 value={props.draftMessage}698 value={props.draftMessage}
389 onChangeText={props.onChangeDraftMessage}699 onChangeText={props.onChangeDraftMessage}
700 onSelectionChange={handleSelectionChange}
390 placeholder={props.placeholder}701 placeholder={props.placeholder}
391 placeholderTextColor={placeholderColor}702 placeholderTextColor={placeholderColor}
392 editable={props.connectionState === "ready"}703 editable={props.connectionState === "ready"}
@@ -463,7 +774,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 70 unmodified lines
463 icon="arrow.up"774 icon="arrow.up"
464 variant="primary"775 variant="primary"
465 disabled={!canSend}776 disabled={!canSend}
466 onPress={props.onSendMessage}777 onPress={handleSend}
467 />778 />
468 )779 )
469 ) : null}780 ) : null}
@@ -508,7 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 38 unmodified lines
508 label={sendLabel}819 label={sendLabel}
509 variant="primary"820 variant="primary"
510 disabled={!canSend}821 disabled={!canSend}
511 onPress={props.onSendMessage}822 onPress={handleSend}
512 />823 />
513 </View>824 </View>
514 ) : null}825 ) : null}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3−0
@@ -53,6 +53,7 @@ export interface ThreadDetailScreenProps { · 52 unmodified lines
53 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;53 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
54 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";54 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
55 readonly activeThreadBusy: boolean;55 readonly activeThreadBusy: boolean;
56 readonly environmentId: string;
56 readonly projectWorkspaceRoot: string | null;57 readonly projectWorkspaceRoot: string | null;
57 readonly selectedThreadQueueCount: number;58 readonly selectedThreadQueueCount: number;
58 readonly serverConfig: T3ServerConfig | null;59 readonly serverConfig: T3ServerConfig | null;
@@ -313,6 +314,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 254 unmodified lines
313 serverConfig={props.serverConfig}314 serverConfig={props.serverConfig}
314 queueCount={props.selectedThreadQueueCount}315 queueCount={props.selectedThreadQueueCount}
315 activeThreadBusy={props.activeThreadBusy}316 activeThreadBusy={props.activeThreadBusy}
317 environmentId={props.environmentId}
318 projectCwd={props.projectWorkspaceRoot}
316 bottomInset={composerBottomInset}319 bottomInset={composerBottomInset}
317 onChangeDraftMessage={props.onChangeDraftMessage}320 onChangeDraftMessage={props.onChangeDraftMessage}
318 onPickDraftImages={props.onPickDraftImages}321 onPickDraftImages={props.onPickDraftImages}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+21−3
@@ -1,11 +1,12 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useCallback, useState } from "react";2import { useCallback, useMemo, useState } from "react";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { pipe } from "effect/Function";5import { pipe } from "effect/Function";
6import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";6import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
7import { useThemeColor } from "../../lib/useThemeColor";7import { useThemeColor } from "../../lib/useThemeColor";
8import { useGitStatus, gitStatusManager } from "../../state/use-git-status";8import { useGitStatus, gitStatusManager } from "../../state/use-git-status";
9import { dismissGitActionResult, useGitActionProgress } from "../../state/use-git-action-state";
9 10
10import { EmptyState } from "../../components/EmptyState";11import { EmptyState } from "../../components/EmptyState";
11import { LoadingScreen } from "../../components/LoadingScreen";12import { LoadingScreen } from "../../components/LoadingScreen";
@@ -20,10 +21,10 @@ import { · 8 unmodified lines
20} from "../../state/use-remote-environment-registry";21} from "../../state/use-remote-environment-registry";
21import { useSelectedThreadDetail } from "../../state/use-thread-detail";22import { useSelectedThreadDetail } from "../../state/use-thread-detail";
22import { useThreadSelection } from "../../state/use-thread-selection";23import { useThreadSelection } from "../../state/use-thread-selection";
24import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
23import { ThreadDetailScreen } from "./ThreadDetailScreen";25import { ThreadDetailScreen } from "./ThreadDetailScreen";
24import { ThreadGitControls } from "./ThreadGitControls";26import { ThreadGitControls } from "./ThreadGitControls";
25import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";27import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
26import { screenTitle } from "./threadPresentation";
27import { useSelectedThreadCommands } from "./use-selected-thread-commands";28import { useSelectedThreadCommands } from "./use-selected-thread-commands";
28import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";29import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";
29import { useSelectedThreadGitState } from "./use-selected-thread-git-state";30import { useSelectedThreadGitState } from "./use-selected-thread-git-state";
@@ -82,6 +83,20 @@ export function ThreadRouteScreen() { · 52 unmodified lines
82 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,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 const handleRefreshGitStatus = useCallback(async () => {100 const handleRefreshGitStatus = useCallback(async () => {
86 if (!selectedThread) return;101 if (!selectedThread) return;
87 await gitStatusManager.refresh({102 await gitStatusManager.refresh({
@@ -152,7 +167,7 @@ export function ThreadRouteScreen() { · 64 unmodified lines
152 );167 );
153 168
154 const headerSubtitle = [169 const headerSubtitle = [
155 screenTitle(serverConfig, null),170 selectedThreadProject?.title ?? null,
156 selectedEnvironmentConnection?.environmentLabel ?? null,171 selectedEnvironmentConnection?.environmentLabel ?? null,
157 ]172 ]
158 .filter(Boolean)173 .filter(Boolean)
@@ -212,6 +227,8 @@ export function ThreadRouteScreen() { · 53 unmodified lines
212 onRunAction={gitActions.onRunSelectedThreadGitAction}227 onRunAction={gitActions.onRunSelectedThreadGitAction}
213 />228 />
214 229
230 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
231
215 <View className="flex-1 bg-screen">232 <View className="flex-1 bg-screen">
216 <ThreadDetailScreen233 <ThreadDetailScreen
217 selectedThread={selectedThreadDetail}234 selectedThread={selectedThreadDetail}
@@ -231,6 +248,7 @@ export function ThreadRouteScreen() { · 13 unmodified lines
231 draftAttachments={composer.draftAttachments}248 draftAttachments={composer.draftAttachments}
232 connectionStateLabel={routeConnectionState}249 connectionStateLabel={routeConnectionState}
233 activeThreadBusy={composer.activeThreadBusy}250 activeThreadBusy={composer.activeThreadBusy}
251 environmentId={selectedThread.environmentId}
234 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}252 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
235 selectedThreadQueueCount={composer.selectedThreadQueueCount}253 selectedThreadQueueCount={composer.selectedThreadQueueCount}
236 onOpenDrawer={handleOpenDrawer}254 onOpenDrawer={handleOpenDrawer}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+0−12
@@ -12,7 +12,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 11 unmodified lines
12import { useThemeColor } from "../../../lib/useThemeColor";12import { useThemeColor } from "../../../lib/useThemeColor";
13 13
14import { AppText as Text } from "../../../components/AppText";14import { AppText as Text } from "../../../components/AppText";
15import { StatusPill } from "../../../components/StatusPill";
16import { useGitStatus } from "../../../state/use-git-status";15import { useGitStatus } from "../../../state/use-git-status";
17import { useThreadSelection } from "../../../state/use-thread-selection";16import { useThreadSelection } from "../../../state/use-thread-selection";
18import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions";17import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions";
@@ -231,17 +230,6 @@ export function GitOverviewSheet() { · 212 unmodified lines
231 </View>230 </View>
232 231
233 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}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 </ScrollView>233 </ScrollView>
246 </View>234 </View>
247 );235 );
apps/mobile/src/features/threads/threadPresentation.ts+0−10
@@ -1,4 +1,3 @@
1import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts";
2import type { StatusTone } from "../../components/StatusPill";1import type { StatusTone } from "../../components/StatusPill";
3import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";2import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
4 3
@@ -52,12 +51,3 @@ export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string · 47 unmodified lines
52 const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl);51 const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl);
53 return url.toString();52 return url.toString();
54}53}
55
56export 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}
apps/mobile/src/features/threads/use-project-actions.ts+43−67
@@ -96,77 +96,53 @@ export function useProjectActions() { · 95 unmodified lines
96 const initialMessageText = input.initialMessageText.trim();96 const initialMessageText = input.initialMessageText.trim();
97 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);97 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
98 98
99 if (input.envMode === "worktree") {99 if (initialMessageText.length === 0) {
100 if (!input.branch || initialMessageText.length === 0) {100 return null;
101 return null;101 }
102 }102 if (input.envMode === "worktree" && !input.branch) {
103 return null;
104 }
103 105
104 await client.orchestration.dispatchCommand({106 const isWorktree = input.envMode === "worktree";
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 });
152 107
153 if (initialMessageText.length > 0 || input.initialAttachments.length > 0) {108 await client.orchestration.dispatchCommand({
154 await client.orchestration.dispatchCommand({109 type: "thread.turn.start",
155 type: "thread.turn.start",110 commandId: CommandId.make(uuidv4()),
156 commandId: CommandId.make(uuidv4()),111 threadId,
157 threadId,112 message: {
158 message: {113 messageId: MessageId.make(uuidv4()),
159 messageId: MessageId.make(uuidv4()),114 role: "user",
160 role: "user",115 text: initialMessageText,
161 text: initialMessageText,116 attachments: input.initialAttachments,
162 attachments: input.initialAttachments,117 },
163 },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 runtimeMode: input.runtimeMode,127 runtimeMode: input.runtimeMode,
165 interactionMode: input.interactionMode,128 interactionMode: input.interactionMode,
166 createdAt: new Date().toISOString(),129 branch: input.branch,
167 });130 worktreePath: isWorktree ? null : input.worktreePath,
168 }131 createdAt,
169 }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 await refreshRemoteData([input.project.environmentId]);147 await refreshRemoteData([input.project.environmentId]);
172 return {148 return {
apps/mobile/src/features/threads/use-selected-thread-git-actions.ts+21−3
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
21 getEnvironmentClient,21 getEnvironmentClient,
22 setPendingConnectionError,22 setPendingConnectionError,
23} from "../../state/use-remote-environment-registry";23} from "../../state/use-remote-environment-registry";
24import { gitActionManager } from "../../state/use-git-action-state";24import { gitActionManager, showGitActionResult } from "../../state/use-git-action-state";
25import { gitBranchManager } from "../../state/use-git-branches";25import { gitBranchManager } from "../../state/use-git-branches";
26import { gitStatusManager } from "../../state/use-git-status";26import { gitStatusManager } from "../../state/use-git-status";
27import { useThreadSelection } from "../../state/use-thread-selection";27import { useThreadSelection } from "../../state/use-thread-selection";
@@ -122,7 +122,9 @@ export function useSelectedThreadGitActions() { · 94 unmodified lines
122 cwd,122 cwd,
123 });123 });
124 } catch (error) {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 return null;128 return null;
127 }129 }
128 },130 },
@@ -264,8 +266,17 @@ export function useSelectedThreadGitActions() { · 135 unmodified lines
264 266
265 const onPullSelectedThreadBranch = useCallback(async () => {267 const onPullSelectedThreadBranch = useCallback(async () => {
266 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {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 await refreshSelectedThreadGitStatus({ quiet: true, cwd });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 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);281 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
271 282
@@ -292,6 +303,13 @@ export function useSelectedThreadGitActions() { · 20 unmodified lines
292 return null;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 if (result.branch.status === "created" && result.branch.name) {313 if (result.branch.status === "created" && result.branch.name) {
296 await syncSelectedThreadBranchState({314 await syncSelectedThreadBranchState({
297 thread,315 thread,
apps/mobile/src/state/use-git-action-state.ts+131−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 getGitActionTargetKey,8 getGitActionTargetKey,
9 gitActionStateAtom,9 gitActionStateAtom,
10} from "@t3tools/client-runtime";10} from "@t3tools/client-runtime";
11import { useCallback, useEffect, useRef, useState } from "react";
11 12
12import { uuidv4 } from "../lib/uuid";13import { uuidv4 } from "../lib/uuid";
13import { appAtomRegistry } from "./atom-registry";14import { appAtomRegistry } from "./atom-registry";
@@ -29,3 +30,133 @@ export function useGitActionState(target: GitActionTarget): GitActionState { · 15 unmodified lines
29 );30 );
30 return targetKey === null ? EMPTY_GIT_ACTION_STATE : state;31 return targetKey === null ? EMPTY_GIT_ACTION_STATE : state;
31}32}
33
34// ---------------------------------------------------------------------------
35// Git action result notification
36// ---------------------------------------------------------------------------
37
38export interface GitActionResultNotification {
39 readonly type: "success" | "error";
40 readonly title: string;
41 readonly description?: string;
42 readonly prUrl?: string;
43}
44
45const RESULT_DISMISS_MS = 5_000;
46
47type ResultListener = (result: GitActionResultNotification | null) => void;
48const resultListeners = new Set<ResultListener>();
49let currentResult: GitActionResultNotification | null = null;
50let dismissTimer: ReturnType<typeof setTimeout> | null = null;
51
52function broadcast(result: GitActionResultNotification | null): void {
53 currentResult = result;
54 for (const listener of resultListeners) {
55 listener(result);
56 }
57}
58
59export function showGitActionResult(result: GitActionResultNotification): void {
60 if (dismissTimer) clearTimeout(dismissTimer);
61 broadcast(result);
62 dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS);
63}
64
65export function dismissGitActionResult(): void {
66 if (dismissTimer) clearTimeout(dismissTimer);
67 broadcast(null);
68}
69
70export 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
91export type GitActionProgressPhase = "idle" | "running" | "success" | "error";
92
93export interface GitActionProgress {
94 readonly phase: GitActionProgressPhase;
95 readonly label: string | null;
96 readonly description: string | null;
97 readonly prUrl?: string;
98}
99
100const EMPTY_PROGRESS: GitActionProgress = {
101 phase: "idle",
102 label: null,
103 description: null,
104};
105
106function 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
113export 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}
apps/web/src/composer-logic.ts+13−89
@@ -1,16 +1,18 @@
1import {
2 detectComposerTrigger as detectComposerTriggerBase,
3 type ComposerTrigger,
4 type ComposerTriggerKind,
5 type ComposerSlashCommand,
6} from "@t3tools/shared/composerTrigger";
7export {
8 replaceTextRange,
9 parseStandaloneComposerSlashCommand,
10} from "@t3tools/shared/composerTrigger";
11export type { ComposerTrigger, ComposerTriggerKind, ComposerSlashCommand };
12
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";13import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";14import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
3 15
4export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill";
5export type ComposerSlashCommand = "model" | "plan" | "default";
6
7export interface ComposerTrigger {
8 kind: ComposerTriggerKind;
9 query: string;
10 rangeStart: number;
11 rangeEnd: number;
12}
13
14const isInlineTokenSegment = (16const isInlineTokenSegment = (
15 segment:17 segment:
16 | { type: "text"; text: string }18 | { type: "text"; text: string }
@@ -216,83 +218,5 @@ export function isCollapsedCursorAdjacentToInlineToken( · 199 unmodified lines
216export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken;218export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken;
217 219
218export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {220export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
219 const cursor = clampCursor(text, cursorInput);221 return detectComposerTriggerBase(text, cursorInput, isWhitespace);
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
276export 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
288export 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 };
298}222}
packages/shared/package.json+4−0
@@ -67,6 +67,10 @@ · 66 unmodified lines
67 "./qrCode": {67 "./qrCode": {
68 "types": "./src/qrCode.ts",68 "types": "./src/qrCode.ts",
69 "import": "./src/qrCode.ts"69 "import": "./src/qrCode.ts"
70 },
71 "./composerTrigger": {
72 "types": "./src/composerTrigger.ts",
73 "import": "./src/composerTrigger.ts"
70 }74 }
71 },75 },
72 "scripts": {76 "scripts": {
packages/shared/src/composerTrigger.ts+117−0
@@ -0,0 +1,117 @@
1export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill";
2export type ComposerSlashCommand = "model" | "plan" | "default";
3
4export interface ComposerTrigger {
5 kind: ComposerTriggerKind;
6 query: string;
7 rangeStart: number;
8 rangeEnd: number;
9}
10
11function 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
16function 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 */
27export 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
95export 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
107export 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}