Skip to content
Draft
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
4 changes: 4 additions & 0 deletions apps/mobile/src/components/ui/AppBar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface AppBarAction {
readonly loading?: boolean;
readonly selected?: boolean;
readonly tintColor?: ColorValue;
/** Android: listed only in the overflow menu, with this subtitle. iOS ignores them. */
readonly overflowOnly?: boolean;
readonly subtitle?: string;
readonly destructive?: boolean;
}

export type AppBarMenuItem =
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
import { openSettings } from "../settings/settings-navigation";
import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab";
import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail";
import { useThreadShareActions } from "../threads/useThreadShareActions";
import type { ThreadShareActionId } from "../threads/threadShareMenu";

const PALETTE_COMMANDS: ReadonlyArray<HardwareKeyboardCommand> = [
"commandPalette",
Expand All @@ -98,6 +100,15 @@ const ACTION_ICONS: Record<string, AppSymbolName> = {
terminal: "terminal",
review: PullRequestGlyph.pullRequest,
copyThreadReference: "link",
"share:handoff": "arrow.triangle.branch",
"share:share-guest": "person.badge.plus",
"share:stop-guest-share": "person.fill.xmark",
};

const SHARE_ACTION_SEARCH_TERMS: Record<ThreadShareActionId, ReadonlyArray<string>> = {
handoff: ["handoff", "fork", "new thread", "continue", "transfer"],
"share-guest": ["share", "guest", "link", "collaborate", "invite", "friend"],
"stop-guest-share": ["share", "guest", "link", "revoke", "unshare"],
};

function itemIcon(item: CommandPaletteItem): AppSymbolName {
Expand Down Expand Up @@ -229,6 +240,11 @@ export function CommandPalette(props: {
const threads = useVisibleThreadShells();
const activeThreadRef = parseActiveThreadPath(props.pathname);
const activeThread = useThreadShell(activeThreadRef);
const threadShareActions = useThreadShareActions(
activeThreadRef?.environmentId ?? null,
activeThreadRef?.threadId ?? null,
);
const runShareAction = threadShareActions.run;
const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const environments = useWorkspaceEnvironments();
const { savedConnectionsById } = useSavedRemoteConnections();
Expand Down Expand Up @@ -399,6 +415,14 @@ export function CommandPalette(props: {
searchTerms,
run: () => runCommand(command),
})),
...threadShareActions.actions.map((action) => ({
key: `share:${action.id}`,
kind: "action" as const,
title: action.title,
...(action.subtitle === undefined ? {} : { detail: action.subtitle }),
searchTerms: SHARE_ACTION_SEARCH_TERMS[action.id],
run: () => runShareAction(action.id),
})),
);
}
const openProjects = projects.filter((project) => project.archivedAt == null);
Expand Down Expand Up @@ -464,6 +488,8 @@ export function CommandPalette(props: {
selectTab,
activeThread,
activeThreadRef,
threadShareActions.actions,
runShareAction,
environments.length,
canTriage,
navigation,
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor";
import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit";
import { useComposerCommandMenu } from "./use-composer-command-menu";
import { useThreadShareActions } from "./useThreadShareActions";
import { resolveHandoffSend } from "@t3tools/shared/threadHandoff";
import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems";
import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request";
import {
Expand Down Expand Up @@ -535,6 +537,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
};

const mentionableEnvironments = useMentionableEnvironments();
const threadShareActions = useThreadShareActions(
props.environmentId,
props.isDraft ? null : props.selectedThread.id,
);
const composerMenu = useComposerCommandMenu({
draftMessage: draftMessage,
ownerKey: composerDraftKey,
Expand All @@ -558,6 +564,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
? undefined
: props.onUpdateInteractionMode,
offersUsageLimits: usageLimitsOffered,
...(props.isDraft ? {} : { handoffDisabledReason: threadShareActions.handoffDisabledReason }),
onUsageLimits:
usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined,
});
Expand Down Expand Up @@ -672,6 +679,29 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
if (openUsageLimits()) onChangeDraftMessage("");
return;
}
const handoffCommand =
props.isDraft || queuedEdit !== null
? null
: resolveHandoffSend({
text: draftMessage,
pickedProviderCommand: composerMenu.pickedProviderCommand,
});
if (handoffCommand) {
const draftAtHandoff = getComposerDraftSnapshot(composerDraftKey);
if (draftAttachments.length > 0 || (draftAtHandoff.context?.records.length ?? 0) > 0) {
Dialog.alert(
"Handoff takes text only",
"Remove the attachments and context, then send it again.",
);
return;
}
const started = await threadShareActions.handoff(handoffCommand.focus);
// Clear only what was sent; the user may have edited the draft meanwhile.
if (started && getComposerDraftSnapshot(composerDraftKey).text === draftAtHandoff.text) {
onChangeDraftMessage("");
}
return;
}
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadGuestShareBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ function alertOnFailure(failureTitle: string, result: AtomCommandResult<unknown,

/**
* Owner controls for a guest link, above the composer: who the link is for and a way to stop
* sharing. Links are created on web. Waiting messages show in the feed, see
* `ThreadGuestPendingMessage`.
* sharing. Links are created from the thread's header menu and the command palette, see
* `useThreadShareActions`. Waiting messages show in the feed, see `ThreadGuestPendingMessage`.
*/
export function ThreadGuestShareBar(props: {
readonly threadRef: ScopedThreadRef | null;
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/threads/ThreadHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function ThreadHeader(
const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } =
useAdaptiveWorkspaceLayout();
const { onOpenTerminal, onMergeBack } = props.gitControls;
const { shareActions, onShareAction } = props;
const native = useThreadHeaderOptions(props);
const androidHeaderActions = useMemo<ReadonlyArray<AppBarAction>>(() => {
const actions: AppBarAction[] = [];
Expand Down Expand Up @@ -61,6 +62,19 @@ export function ThreadHeader(
onPress: onMergeBack,
});
}
if (onShareAction) {
for (const action of shareActions ?? []) {
actions.push({
accessibilityLabel: action.title,
icon: action.icon,
onPress: () => onShareAction(action.id),
overflowOnly: true,
...(action.subtitle === undefined ? {} : { subtitle: action.subtitle }),
...(action.disabled ? { disabled: true } : {}),
...(action.destructive ? { destructive: true } : {}),
});
}
}
return actions;
}, [
props.inspectorMode,
Expand All @@ -74,6 +88,8 @@ export function ThreadHeader(
props.hasThreadCwd,
props.hasWorkspaceRoot,
props.gitControls.canOpenTerminal,
shareActions,
onShareAction,
]);

return (
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useThreadShareActions } from "./useThreadShareActions";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
Expand Down Expand Up @@ -384,6 +385,10 @@ function ThreadRouteContent(
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const shareActions = useThreadShareActions(
selectedThread?.environmentId ?? null,
selectedThread?.id ?? null,
);
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, {
label: "resume queue",
Expand Down Expand Up @@ -1023,6 +1028,8 @@ function ThreadRouteContent(
onOpenGitInspector={handleOpenGitInspector}
onOpenFilesInspector={handleOpenFilesInspector}
onReturnToThread={props.onReturnToThread}
shareActions={shareActions.actions}
onShareAction={shareActions.run}
/>
)
}
Expand Down
94 changes: 94 additions & 0 deletions apps/mobile/src/features/threads/threadShareMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { GuestShareCreateError } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { buildThreadShareActions, guestShareCreateFailureMessage } from "./threadShareMenu";

describe("buildThreadShareActions", () => {
it("offers handoff and a guest link on an idle thread", () => {
const actions = buildThreadShareActions({
canOperateThread: true,
handoffDisabledReason: null,
canCreateGuestShare: true,
hasGuestShare: false,
});
expect(actions.map((action) => [action.id, action.title, action.disabled ?? false])).toEqual([
["handoff", "Hand off to new thread", false],
["share-guest", "Share with guest", false],
]);
});

it("shows why handoff is not available yet", () => {
const [handoff] = buildThreadShareActions({
canOperateThread: true,
handoffDisabledReason: "Hand off after the first reply finishes.",
canCreateGuestShare: false,
hasGuestShare: false,
});
expect(handoff).toMatchObject({
id: "handoff",
disabled: true,
subtitle: "Hand off after the first reply finishes.",
});
});

it("offers a replacement link and the way out while a share is active", () => {
const actions = buildThreadShareActions({
canOperateThread: true,
handoffDisabledReason: null,
canCreateGuestShare: true,
hasGuestShare: true,
});
expect(actions.map((action) => action.title)).toEqual([
"Hand off to new thread",
"New guest link",
"Stop sharing",
]);
expect(actions.at(-1)?.destructive).toBe(true);
});

it("keeps Stop sharing without permission to create links", () => {
const actions = buildThreadShareActions({
canOperateThread: true,
handoffDisabledReason: null,
canCreateGuestShare: false,
hasGuestShare: true,
});
expect(actions.map((action) => action.id)).toEqual(["handoff", "stop-guest-share"]);
});

it("offers nothing to a viewer who cannot operate the thread", () => {
expect(
buildThreadShareActions({
canOperateThread: false,
handoffDisabledReason: null,
canCreateGuestShare: true,
hasGuestShare: true,
}),
).toEqual([]);
});
});

describe("guestShareCreateFailureMessage", () => {
it("tells the user where to turn guest links on", () => {
expect(guestShareCreateFailureMessage(new GuestShareCreateError({ reason: "disabled" }))).toBe(
"Guest links are off on this machine. Turn on Settings > Labs > Guest links in T3 Code on the computer running it, then try again.",
);
// RPC failures can arrive as plain tagged objects.
expect(
guestShareCreateFailureMessage({
_tag: "GuestShareCreateError",
reason: "connect_unavailable",
}),
).toBe(
"Guest links need T3 Connect. Start T3 Connect on the computer running T3 Code, then try again.",
);
});

it("falls back to the error's own message", () => {
expect(guestShareCreateFailureMessage(new Error("Socket closed"))).toBe("Socket closed");
expect(guestShareCreateFailureMessage(undefined)).toBe("Could not create a guest link.");
expect(
guestShareCreateFailureMessage({ _tag: "GuestShareCreateError", reason: "unknown" }),
).toBe("Could not create a guest link.");
});
});
84 changes: 84 additions & 0 deletions apps/mobile/src/features/threads/threadShareMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { AppSymbolName } from "../../components/ui/AppSymbol";

/**
* Thread-level share and handoff actions, in the order every mobile entry point lists them: the
* Android header overflow, the iOS header menu, and the command palette.
*/
export type ThreadShareActionId = "handoff" | "share-guest" | "stop-guest-share";

export interface ThreadShareAction {
readonly id: ThreadShareActionId;
readonly title: string;
readonly subtitle?: string;
readonly icon: AppSymbolName;
readonly disabled?: boolean;
readonly destructive?: boolean;
}

export function buildThreadShareActions(input: {
readonly canOperateThread: boolean;
/** Null when the thread can be handed off now. */
readonly handoffDisabledReason: string | null;
readonly canCreateGuestShare: boolean;
readonly hasGuestShare: boolean;
}): ThreadShareAction[] {
if (!input.canOperateThread) return [];
const actions: ThreadShareAction[] = [
{
id: "handoff",
title: "Hand off to new thread",
subtitle: input.handoffDisabledReason ?? "Continue in a new thread with this conversation",
icon: "arrow.triangle.branch",
...(input.handoffDisabledReason === null ? {} : { disabled: true }),
},
];
if (input.canCreateGuestShare) {
actions.push({
id: "share-guest",
// A thread has one guest link; a new one ends the current one.
title: input.hasGuestShare ? "New guest link" : "Share with guest",
subtitle: input.hasGuestShare
? "Ends the current guest link"
: "One guest can watch and suggest messages",
icon: "person.badge.plus",
});
}
if (input.hasGuestShare) {
actions.push({
id: "stop-guest-share",
title: "Stop sharing",
subtitle: "The guest loses access",
icon: "person.fill.xmark",
destructive: true,
});
}
return actions;
}

const GUEST_SHARE_CREATE_FAILURES = {
disabled:
"Guest links are off on this machine. Turn on Settings > Labs > Guest links in T3 Code on the computer running it, then try again.",
connect_unavailable:
"Guest links need T3 Connect. Start T3 Connect on the computer running T3 Code, then try again.",
thread_unavailable: "This thread cannot be shared.",
read_failed: "Failed to read the shared thread.",
changed: "The guest link changed while it was being created. Try again.",
} as const;

/** The message for a failed guest link, by the server's `GuestShareCreateError` reason. */
export function guestShareCreateFailureMessage(error: unknown): string {
if (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
error._tag === "GuestShareCreateError" &&
"reason" in error &&
typeof error.reason === "string" &&
error.reason in GUEST_SHARE_CREATE_FAILURES
) {
return GUEST_SHARE_CREATE_FAILURES[error.reason as keyof typeof GUEST_SHARE_CREATE_FAILURES];
}
return error instanceof Error && error.message.length > 0
? error.message
: "Could not create a guest link.";
}
Loading
Loading