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
45 changes: 45 additions & 0 deletions apps/mobile/src/features/settings/ThreadSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function SettingsLabsRouteScreen() {
contentContainerClassName="gap-6 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<ThreadSplitSettingsSection />
<BetaSettingsSection />
<LegacySettingsSection />
</ScrollView>
Expand Down Expand Up @@ -294,3 +295,47 @@ function LegacySettingsSection() {
</View>
);
}

function ThreadSplitSettingsSection() {
const { environments } = useEnvironments();
const connected = environments.filter(
(environment) => environment.connection.phase === "connected",
);
const writable = useEnvironmentsWithScope(connected, AuthSettingsWriteScope);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
label: "thread split suggestions setting",
reportFailure: true,
});
return (
<SettingsSection title="Thread suggestions">
{connected.map((environment) => {
const policy = environment.serverConfig?.managedPolicy;
const managed = isManagedSettingPath(policy, "threadSplitSuggestionsEnabled");
return (
<SettingsSwitchRow
key={environment.environmentId}
icon="square.and.pencil"
label={environment.label}
subtitle={
environment.serverConfig?.settings.threadSplitModelEndpoint
? "Suggest a fresh thread for an independent message. Nothing moves until you choose it."
: "Configure the local decision model in Labs on web or desktop first."
}
value={environment.serverConfig?.settings.threadSplitSuggestionsEnabled === true}
disabled={
managed ||
!writable.has(environment.environmentId) ||
!environment.serverConfig?.settings.threadSplitModelEndpoint
}
onValueChange={(value) =>
void updateSettings({
environmentId: environment.environmentId,
input: { patch: { threadSplitSuggestionsEnabled: value } },
})
}
/>
);
})}
</SettingsSection>
);
}
29 changes: 28 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ThreadSplitSuggestion } from "./ThreadSplitSuggestion";
import { ProjectKnowledgeEntry } from "../../components/ProjectKnowledgeEntry";
import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar";
import { Button } from "../../components/ui/Button";
Expand Down Expand Up @@ -449,7 +450,11 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const composerDraftKey = props.draftKey ?? composerOwnerKey;
const { text: draftMessage, attachments: draftAttachments } = useComposerDraft(composerDraftKey);
const {
text: draftMessage,
attachments: draftAttachments,
context: draftContext,
} = useComposerDraft(composerDraftKey);
const queuedEdit = props.queuedEdit ?? null;
const hasContent =
draftMessage.trim().length > 0 ||
Expand Down Expand Up @@ -903,6 +908,28 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
/>
) : null}

<ThreadSplitSuggestion
key={composerOwnerKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
projectId={props.selectedThread.projectId}
draftKey={composerDraftKey}
message={draftMessage}
enabled={
props.serverConfig?.settings.threadSplitSuggestionsEnabled === true &&
Boolean(props.serverConfig.settings.threadSplitModelEndpoint) &&
!props.isDraft &&
!props.hidden &&
!props.activeThreadBusy &&
props.canOperateThread &&
props.connectionState === "connected" &&
props.queueCount === 0 &&
queuedEdit === null &&
draftAttachments.length === 0 &&
!draftContext
}
/>

<ComposerSurface
animateLayout={false}
style={{
Expand Down
108 changes: 108 additions & 0 deletions apps/mobile/src/features/threads/ThreadSplitSuggestion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";

const state = vi.hoisted(() => ({
suggest: vi.fn(),
}));

vi.mock("react-native", () => ({
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@react-navigation/native", () => ({
StackActions: { push: (name: string, params: unknown) => ({ name, params }) },
useIsFocused: () => true,
useNavigation: () => ({ dispatch: vi.fn() }),
}));
vi.mock("../../components/ui/Button", () => ({
Button: ({ label, onPress }: { label: string; onPress: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => true }));
vi.mock("../../state/threads", () => ({
threadEnvironment: { suggestSplit: { permissionAtom: () => true } },
}));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.suggest }));
vi.mock("../../state/use-composer-drafts", () => ({
createNewTaskDraft: () => "draft-new",
getComposerDraftSnapshot: () => ({ text: "", attachments: [], context: undefined }),
setComposerDraftText: vi.fn(),
updateComposerDraftSettings: vi.fn(),
}));

import { ThreadSplitSuggestion } from "./ThreadSplitSuggestion";

const environmentId = EnvironmentId.make("environment-test");
const threadId = ThreadId.make("thread-test");
const projectId = ProjectId.make("project-test");

function mount(key: string, message: string) {
const container = document.createElement("div");
const root = createRoot(container);
act(() =>
root.render(
<ThreadSplitSuggestion
key={key}
environmentId={environmentId}
threadId={threadId}
projectId={projectId}
draftKey="draft-test"
message={message}
enabled
/>,
),
);
return { container, root };
}

describe("ThreadSplitSuggestion", () => {
it("keeps a dismissed suggestion hidden across remounts until the draft is cleared", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.useFakeTimers();
try {
state.suggest.mockResolvedValue({ _tag: "Success", value: { suggest: true } });
const first = mount("a", "Write release notes");
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(state.suggest).toHaveBeenCalledTimes(1);
const dismiss = first.container.querySelector("button:last-of-type");
expect(first.container.textContent).toContain("Start a new thread");
act(() => dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(first.container.textContent).not.toContain("Start a new thread");
act(() => first.root.unmount());

const second = mount("b", "Write release notes");
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(state.suggest).toHaveBeenCalledTimes(1);
expect(second.container.textContent).not.toContain("Start a new thread");
act(() => second.root.unmount());

const cleared = mount("c", "");
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
act(() => cleared.root.unmount());

const fresh = mount("d", "A different draft");
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(state.suggest).toHaveBeenCalledTimes(2);
await act(async () => {
await Promise.resolve();
});
expect(fresh.container.textContent).toContain("Start a new thread");
act(() => fresh.root.unmount());
} finally {
vi.useRealTimers();
}
});
});
108 changes: 108 additions & 0 deletions apps/mobile/src/features/threads/ThreadSplitSuggestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useEffect, useEffectEvent, useState } from "react";
import { StackActions, useIsFocused, useNavigation } from "@react-navigation/native";
import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import { View } from "react-native";
import { Button } from "../../components/ui/Button";
import { threadEnvironment } from "../../state/threads";
import { useAtomValue } from "@effect/atom-react";
import { useAtomCommand } from "../../state/use-atom-command";
import {
createNewTaskDraft,
getComposerDraftSnapshot,
setComposerDraftText,
updateComposerDraftSettings,
} from "../../state/use-composer-drafts";
import { scopedThreadKey } from "../../lib/scopedEntities";

const dismissedThreadKeys = new Set<string>();

type ThreadSplitSuggestionProps = {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly projectId: ProjectId;
readonly draftKey: string;
readonly message: string;
readonly enabled: boolean;
};

export function ThreadSplitSuggestion(props: ThreadSplitSuggestionProps) {
const focused = useIsFocused();
const canSuggest = useAtomValue(
threadEnvironment.suggestSplit.permissionAtom(props.environmentId),
);
const threadKey = scopedThreadKey(props.environmentId, props.threadId);
const [dismissed, setDismissed] = useState(() => dismissedThreadKeys.has(threadKey));
const hasMessage = Boolean(props.message.trim());
if (dismissed && !hasMessage) {
dismissedThreadKeys.delete(threadKey);
setDismissed(false);
}
if (!props.enabled || !focused || !canSuggest || !hasMessage || dismissed) return null;
return (
<ActiveThreadSplitSuggestion
key={JSON.stringify([props.environmentId, props.threadId, props.message])}
{...props}
onDismiss={() => {
dismissedThreadKeys.add(threadKey);
setDismissed(true);
}}
/>
);
}

function ActiveThreadSplitSuggestion(
props: ThreadSplitSuggestionProps & { readonly onDismiss: () => void },
) {
const navigation = useNavigation();
const [suggestedMessage, setSuggestedMessage] = useState<string | null>(null);
const suggest = useAtomCommand(threadEnvironment.suggestSplit, {
reportFailure: false,
reportDefect: false,
});
const request = useEffectEvent(suggest);
const { environmentId, threadId, message } = props;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- debounce remote inference and discard responses after the draft or destination changes
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
void request({ environmentId, input: { threadId, message } }).then((result) => {
if (!cancelled)
setSuggestedMessage(result._tag === "Success" && result.value.suggest ? message : null);
});
}, 1_000);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [environmentId, threadId, message]);

if (suggestedMessage !== message) return null;

return (
<View className="flex-row items-center">
<Button
label="Start a new thread with this message"
tone="text"
onPress={() => {
const draft = getComposerDraftSnapshot(props.draftKey);
if (draft.text !== message || draft.attachments.length > 0 || draft.context) return;
const draftId = createNewTaskDraft({ environmentId, projectId: props.projectId });
setComposerDraftText(draftId, message);
updateComposerDraftSettings(draftId, {
modelSelection: draft.modelSelection,
runtimeMode: draft.runtimeMode,
interactionMode: draft.interactionMode,
workspaceSelection: draft.workspaceSelection,
});
navigation.dispatch(
StackActions.push("Thread", {
draft: { draftId, environmentId, projectId: props.projectId },
}),
);
setComposerDraftText(props.draftKey, "");
}}
/>
<Button label="Dismiss thread suggestion" tone="text" onPress={props.onDismiss} />
</View>
);
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const reactTests = [
"src/features/settings/components/BranchNamingSettings.test.tsx",
"src/features/settings/components/SettingsDragHandle.test.tsx",
"src/features/threads/ThreadMarkdownImage.test.tsx",
"src/features/threads/ThreadSplitSuggestion.test.tsx",
"src/features/threads/UsageLimitRecoveryCard.test.tsx",
"src/features/threads/NewTaskDraftRouteScreen.test.tsx",
"src/native/NativeHeaderToolbar.test.tsx",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];

// Trace queries and dashboards depend on these labels, including deviations from method prefixes.
const RPC_AGGREGATES = {
[WS_METHODS.orchestrationSuggestThreadSplit]: "orchestration",
[WS_METHODS.projectKnowledgeSearch]: "projectKnowledge",
[WS_METHODS.conversationSummariesList]: "conversationSummaries",
[WS_METHODS.conversationSummariesDescribe]: "conversationSummaries",
Expand Down
Loading
Loading