Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "pi") {
const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F";
return (
<Svg width={size} height={size} viewBox="165.29 165.29 469.43 469.43" fill="none">
<Path
fill={foreground}
fillRule="evenodd"
d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
/>
<Path fill={foreground} d="M517.36 400H634.72V634.72H517.36Z" />
</Svg>
);
}

if (props.provider === "opencode") {
return (
<Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
import { parsePiSessionCommand } from "@t3tools/client-runtime/state/threads";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ShimmeringWorkContent } from "./thread-work-log";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
Expand Down Expand Up @@ -1010,6 +1011,15 @@ export function NewTaskDraftScreen(props: {
);
return;
}
if (
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === modelSelection.instanceId,
)?.driver === "pi" &&
parsePiSessionCommand(initialMessageText)
) {
Alert.alert("Start a Pi thread first", "Send a message before using session commands.");
return;
}
// A failed-send restore can leave the draft over the cap on purpose (it
// never drops the user's files); starting anyway would upload everything
// and have the server reject the turn.
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface ThreadComposerProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onSendMessage: (providerCommand?: string) => Promise<MessageId | null>;
/** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */
readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
Expand Down Expand Up @@ -409,6 +409,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
// With attachments aboard the pick just inserts the text, so it sends as a prompt.
onUsageLimits:
usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined,
onProviderCommand:
props.draftAttachments.length === 0 ? (name) => void onSendMessage(name) : undefined,
});
const voiceInput = useVoiceInputController({
ownerKey: composerOwnerKey,
Expand Down
71 changes: 37 additions & 34 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onSendMessage: (providerCommand?: string) => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
Expand Down Expand Up @@ -693,40 +693,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
selectedThreadKey,
]);

const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
const hasUserMessage = selectedThreadFeed.some(
(entry) => entry.type === "message" && entry.message.role === "user",
);
const messageId = await props.onSendMessage();
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}
const handleSendMessage = useCallback(
async (providerCommand?: string) => {
const targetThreadKey = selectedThreadKey;
const hasUserMessage = selectedThreadFeed.some(
(entry) => entry.type === "message" && entry.message.role === "user",
);
const messageId = await props.onSendMessage(providerCommand);
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}

// A sent message makes the snapshot stale; a refused send leaves it in place.
clearUsageLimitsFor(targetThreadKey);

setSubmittedMessageId(messageId);
setAnchorMessageId(
resolveThreadFeedSubmissionAnchor({
currentAnchorMessageId: anchorMessageId,
submittedMessageId: messageId,
hasStartedTurn: props.selectedThread.latestTurn !== null,
hasUserMessage,
queuedMessageCount: props.selectedThreadQueueCount,
}),
);
composerEditorRef.current?.blur();
return messageId;
}, [
anchorMessageId,
clearUsageLimitsFor,
props.onSendMessage,
props.selectedThread.latestTurn,
props.selectedThreadQueueCount,
selectedThreadFeed,
selectedThreadKey,
]);
// A sent message makes the snapshot stale; a refused send leaves it in place.
clearUsageLimitsFor(targetThreadKey);

setSubmittedMessageId(messageId);
setAnchorMessageId(
resolveThreadFeedSubmissionAnchor({
currentAnchorMessageId: anchorMessageId,
submittedMessageId: messageId,
hasStartedTurn: props.selectedThread.latestTurn !== null,
hasUserMessage,
queuedMessageCount: props.selectedThreadQueueCount,
}),
);
composerEditorRef.current?.blur();
return messageId;
},
[
anchorMessageId,
clearUsageLimitsFor,
props.onSendMessage,
props.selectedThread.latestTurn,
props.selectedThreadQueueCount,
selectedThreadFeed,
selectedThreadKey,
],
);

const collapseComposer = useCallback(() => {
composerEditorRef.current?.blur();
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export function buildComposerSlashCommandItems(input: {
for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
if (!command.name.toLowerCase().includes(query)) continue;
if (command.name === "compact" && !input.hasCompactableConversation) continue;
if (
!input.hasThread &&
input.selectedProviderStatus?.driver === "pi" &&
["copy", "export", "share"].includes(command.name)
)
continue;
// T3's own limits command is answered by the thread composer; New Task has
// nowhere to show it. A provider's same-named command is left alone.
if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits) {
Expand Down Expand Up @@ -143,7 +149,7 @@ export function resolveComposerCommandSelection(input: {
} else if (item.type === "slash-command") {
replacement = `/${item.command} `;
} else if (item.type === "provider-slash-command") {
replacement = `/${item.command.name} `;
replacement = `/${item.command.name}${item.command.argumentMode === "none" ? "" : " "}`;
}
return {
...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement),
Expand All @@ -165,6 +171,7 @@ export function useComposerCommandMenu({
onChangeDraftMessage,
onUpdateInteractionMode,
onUsageLimits,
onProviderCommand,
}: {
readonly draftMessage: string;
readonly ownerKey: string | null;
Expand All @@ -180,6 +187,7 @@ export function useComposerCommandMenu({
readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
/** Picking /usage-limits is the action itself; the draft keeps nothing of it. */
readonly onUsageLimits?: () => void;
readonly onProviderCommand?: (name: string) => void;
}) {
const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
const previousOwnerKeyRef = useRef(ownerKey);
Expand Down Expand Up @@ -433,6 +441,18 @@ export function useComposerCommandMenu({
return;
}

if (
item.type === "provider-slash-command" &&
item.command.argumentMode === "none" &&
onProviderCommand
) {
const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
setSelection({ start: cleared.cursor, end: cleared.cursor });
onChangeDraftMessage(cleared.text);
onProviderCommand(item.command.name);
return;
}

const result = resolveComposerCommandSelection({
draftMessage,
trigger,
Expand All @@ -452,6 +472,7 @@ export function useComposerCommandMenu({
onChangeDraftMessage,
onUpdateInteractionMode,
onUsageLimits,
onProviderCommand,
selectedProviderStatus?.showInteractionModeToggle,
trigger,
],
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/lib/attachmentDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,18 @@ export async function shareLocalAttachment(input: {
cached.preview.dispose();
}
}

/** Save generated text through the same cache and native handoff as downloaded files. */
export async function shareTextFile(input: {
readonly name: string;
readonly mimeType: string;
readonly text: string;
}) {
const cached = await createCachedAttachmentFile(input);
try {
cached.file.write(input.text);
await cached.preview.share(new AbortController().signal);
} finally {
cached.preview.dispose();
}
Comment on lines +230 to +242
}
Loading
Loading