Skip to content
Closed
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: 10 additions & 4 deletions apps/mobile/src/features/threads/FreshSessionNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { View } from "react-native";
import { Notice } from "../../components/ui/Notice";
import { setFreshSessionPending, useComposerDraftSettings } from "../../state/use-composer-drafts";

/** Shown while `/clear` is armed; dismissing it keeps the current session. */
/** Shown while `/clear` or `/recap` is armed; dismissing it keeps the current session. */
export function FreshSessionNotice(props: { readonly threadKey: string }) {
if (useComposerDraftSettings(props.threadKey).freshContext !== true) return null;
const settings = useComposerDraftSettings(props.threadKey);
if (settings.freshContext !== true) return null;
const withRecap = settings.contextHandoffOnFresh === true;
return (
<View className="px-4 pb-3">
<Notice
message="Your next message starts a fresh session. The agent won't see earlier messages in this thread."
icon="arrow.clockwise"
message={
withRecap
? "Your next message starts a fresh session with a written recap. Distinct from Compact and Clear."
: "Your next message starts a fresh session. The agent won't see earlier messages in this thread."
}
icon={withRecap ? "doc.text" : "arrow.clockwise"}
floating
onDismiss={() => setFreshSessionPending(props.threadKey, false)}
dismissLabel="Keep the current session"
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export interface ThreadComposerProps {
readonly reportedModelSelection?: ModelSelection | null;
readonly hasCompactableConversation: boolean;
/** Arms a fresh session for the next message; absent where `/clear` is unavailable. */
readonly onFreshSession?: () => void;
readonly onFreshSession?: (options?: { readonly withRecap?: boolean }) => void;
readonly offersSideChat?: boolean;
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1873,7 +1873,8 @@ function ThreadDetailBody(
}
{...(freshSessionAvailable
? {
onFreshSession: () => setFreshSessionPending(selectedThreadKey, true),
onFreshSession: (options?: { readonly withRecap?: boolean }) =>
setFreshSessionPending(selectedThreadKey, true, options),
}
: {})}
offersSideChat={
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3578,8 +3578,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
info.item.message.projectedItem?.item.type === "user_message" &&
info.item.message.projectedItem.item.freshContext ? (
<ThreadContextDivider
label="New session"
icon="arrow.clockwise"
label={
info.item.message.projectedItem.item.contextHandoffOnFresh === true
? "New session with recap"
: "New session"
}
icon={
info.item.message.projectedItem.item.contextHandoffOnFresh === true
? "doc.text"
: "arrow.clockwise"
}
iconColor={iconSubtleColor}
/>
) : null}
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/use-composer-command-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ describe("mobile slash commands", () => {
expect(build(false)).toEqual(["pcmd:clear"]);
expect(build(true, false)).toEqual([]);
});

it("offers /recap for Start fresh with recap when fresh sessions are available", () => {
const items = buildComposerSlashCommandItems({
query: "rec",
atMessageStart: true,
hasThread: true,
offersFreshSession: true,
selectedProviderStatus: {
driver: ProviderDriverKind.make("claudeAgent"),
slashCommands: [],
},
});
expect(items.map((item) => item.id)).toEqual(["cmd:recap"]);
expect(items[0]).toMatchObject({
type: "slash-command",
command: "recap",
label: "/recap",
description: "Start fresh with recap",
});
});
});

describe("mobile model mentions", () => {
Expand Down
26 changes: 21 additions & 5 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,16 @@ export function buildComposerSlashCommandItems(input: {
type: "slash-command",
command: "clear",
label: "/clear",
description: "Start a fresh session",
description: "Start a fresh session without earlier context",
});
}
if (input.offersFreshSession && "recap".includes(query)) {
items.push({
id: "cmd:recap",
type: "slash-command",
command: "recap",
label: "/recap",
description: "Start fresh with recap",
});
}
for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
Expand Down Expand Up @@ -287,8 +296,11 @@ export function useComposerCommandMenu({
readonly onUsageLimits?: () => void;
readonly modelOptions?: ReadonlyArray<ModelOption>;
readonly modelProviders?: ReadonlyArray<ServerProvider>;
/** Arms a fresh session for the next message; absent where `/clear` is unavailable. */
readonly onFreshSession?: () => void;
/**
* Arms a fresh session for the next message; absent where `/clear` / `/recap` are unavailable.
* Pass `{ withRecap: true }` for Start fresh with recap.
*/
readonly onFreshSession?: (options?: { readonly withRecap?: boolean }) => void;
readonly offersSideChat?: boolean;
}) {
const [localSelection, setLocalSelection] = useState(() => ({
Expand Down Expand Up @@ -632,11 +644,15 @@ export function useComposerCommandMenu({
return;
}

if (item.type === "slash-command" && item.command === "clear" && onFreshSession) {
if (
item.type === "slash-command" &&
(item.command === "clear" || item.command === "recap") &&
onFreshSession
) {
const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
setSelection({ start: cleared.cursor, end: cleared.cursor });
onChangeDraftMessage(cleared.text);
onFreshSession();
onFreshSession(item.command === "recap" ? { withRecap: true } : undefined);
return;
}
if (
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])),
compactBeforeSend: Schema.optional(Schema.Boolean),
freshContext: Schema.optional(Schema.Boolean),
contextHandoffOnFresh: Schema.optional(Schema.Boolean),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
creation: Schema.optional(QueuedThreadCreationSchema),
Expand Down Expand Up @@ -95,8 +96,10 @@ export interface QueuedThreadMessage {
readonly dispatchMode?: ComposerDispatchMode;
/** Sends "When you return"'s `/compact` ahead of the message, which queues behind it. */
readonly compactBeforeSend?: boolean;
/** Starts a new provider session (`/clear`). */
/** Starts a new provider session (`/clear` or `/recap`). */
readonly freshContext?: boolean;
/** With `freshContext`, deliver a durable written recap (`/recap`). */
readonly contextHandoffOnFresh?: boolean;
readonly creation?: QueuedThreadCreation;
readonly createdAt: string;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/threadOutboxDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export async function restoreRejectedQueuedMessage(
...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}),
...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}),
...(queuedMessage.freshContext ? { freshContext: true } : {}),
...(queuedMessage.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}),
...(queuedMessage.creation
? {
workspaceSelection: {
Expand Down Expand Up @@ -953,6 +954,7 @@ export function createThreadOutboxDelivery(): {
dispatchMode: queuedMessage.dispatchMode ?? "start",
...(queuedMessage.freshContext ? { freshContext: true } : {}),
resendAfterSocketLoss: false,
...(queuedMessage.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}),
},
});
if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
Expand Down
41 changes: 35 additions & 6 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,10 @@ export interface ComposerDraft {
readonly modelSelection?: ModelSelection;
readonly runtimeMode?: RuntimeMode;
readonly interactionMode?: ProviderInteractionMode;
/** `/clear` armed this draft: its next message starts a fresh provider session. */
/** `/clear` or `/recap` armed this draft: its next message starts a fresh provider session. */
readonly freshContext?: boolean;
/** With `freshContext`, `/recap` also delivers a durable written handoff. */
readonly contextHandoffOnFresh?: boolean;
readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
readonly project?: ComposerDraftProject;
}
Expand Down Expand Up @@ -443,6 +445,7 @@ export type ComposerDraftSettingsUpdate = Pick<
| "runtimeMode"
| "interactionMode"
| "freshContext"
| "contextHandoffOnFresh"
| "workspaceSelection"
| "project"
>;
Expand Down Expand Up @@ -477,6 +480,7 @@ const ComposerDraftSchema = Schema.Struct({
runtimeMode: Schema.optional(RuntimeModeSchema),
interactionMode: Schema.optional(ProviderInteractionModeSchema),
freshContext: Schema.optional(Schema.Boolean),
contextHandoffOnFresh: Schema.optional(Schema.Boolean),
workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
project: Schema.optional(ComposerDraftProjectSchema),
});
Expand Down Expand Up @@ -657,6 +661,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
draft.runtimeMode === undefined &&
draft.interactionMode === undefined &&
draft.freshContext !== true &&
draft.contextHandoffOnFresh !== true &&
draft.workspaceSelection === undefined
);
}
Expand Down Expand Up @@ -1556,15 +1561,34 @@ export function releaseComposerDraftModelSelection(
});
}

export function setFreshSessionPending(draftKey: string, pending: boolean): void {
if ((getComposerDraftSnapshot(draftKey).freshContext === true) === pending) return;
updateComposerDraftSettings(draftKey, { freshContext: pending ? true : undefined });
export function setFreshSessionPending(
draftKey: string,
pending: boolean,
options?: { readonly withRecap?: boolean },
): void {
const draft = getComposerDraftSnapshot(draftKey);
const withRecap = pending && options?.withRecap === true;
if (
(draft.freshContext === true) === pending &&
(draft.contextHandoffOnFresh === true) === withRecap
) {
return;
}
updateComposerDraftSettings(draftKey, {
freshContext: pending ? true : undefined,
contextHandoffOnFresh: withRecap ? true : undefined,
});
}

export function isFreshSessionPending(draftKey: string): boolean {
return getComposerDraftSnapshot(draftKey).freshContext === true;
}

export function isFreshSessionRecapPending(draftKey: string): boolean {
const draft = getComposerDraftSnapshot(draftKey);
return draft.freshContext === true && draft.contextHandoffOnFresh === true;
}

export function subscribeComposerDraftModelReconciliation(input: {
readonly registry: AtomRegistry.AtomRegistry;
readonly threadRefsAtom: Atom.Atom<ReadonlyArray<ScopedThreadRef>>;
Expand Down Expand Up @@ -1814,6 +1838,7 @@ export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): bool
a.runtimeMode === b.runtimeMode &&
a.interactionMode === b.interactionMode &&
a.freshContext === b.freshContext &&
a.contextHandoffOnFresh === b.contextHandoffOnFresh &&
a.workspaceSelection === b.workspaceSelection
);
}
Expand Down Expand Up @@ -1844,6 +1869,7 @@ export function undoComposerDraftMergeState(
| "runtimeMode"
| "interactionMode"
| "freshContext"
| "contextHandoffOnFresh"
| "workspaceSelection",
>(
key: K,
Expand All @@ -1865,6 +1891,7 @@ export function undoComposerDraftMergeState(
runtimeMode: undoSetting("runtimeMode"),
interactionMode: undoSetting("interactionMode"),
freshContext: undoSetting("freshContext"),
contextHandoffOnFresh: undoSetting("contextHandoffOnFresh"),
workspaceSelection: undoSetting("workspaceSelection"),
};
return withComposerDraft(current, draftKey, draft);
Expand Down Expand Up @@ -2097,7 +2124,7 @@ export const composerDraftUploadSourcesAtom = Atom.make((get): ComposerDraftUplo

type ComposerDraftSettings = Pick<
ComposerDraft,
"modelSelection" | "runtimeMode" | "interactionMode" | "freshContext"
"modelSelection" | "runtimeMode" | "interactionMode" | "freshContext" | "contextHandoffOnFresh"
>;
const EMPTY_DRAFT_SETTINGS: ComposerDraftSettings = {};
const composerDraftSettingsAtom = Atom.family((draftKey: string) =>
Expand All @@ -2108,14 +2135,16 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) =>
runtimeMode: draft.runtimeMode,
interactionMode: draft.interactionMode,
freshContext: draft.freshContext,
contextHandoffOnFresh: draft.contextHandoffOnFresh,
};
}).pipe(
Atom.withEquality(
(a: ComposerDraftSettings, b: ComposerDraftSettings) =>
a.modelSelection === b.modelSelection &&
a.runtimeMode === b.runtimeMode &&
a.interactionMode === b.interactionMode &&
a.freshContext === b.freshContext,
a.freshContext === b.freshContext &&
a.contextHandoffOnFresh === b.contextHandoffOnFresh,
),
),
);
Expand Down
21 changes: 16 additions & 5 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ import {
useComposerDraft,
useComposerDraftSettings,
} from "./use-composer-drafts";
import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger";
import {
isClearComposerCommand,
isRecapComposerCommand,
parseSideChatCommand,
} from "@t3tools/shared/composerTrigger";
import {
compactionBlocksSteering,
resolveComposerDispatchMode,
Expand Down Expand Up @@ -647,16 +651,18 @@ export function useThreadComposerState(input: {
}
return null;
}
// A redirect's text answers its approval, so /clear there is that text, not the command.
// A redirect's text answers its approval, so /clear or /recap there is that text, not the command.
if (
beforeDispatch === undefined &&
selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.freshContext === true &&
thread.latestRun != null &&
attachments.length === 0 &&
draft.context === undefined &&
isClearComposerCommand(text)
(isClearComposerCommand(text) || isRecapComposerCommand(text))
) {
setFreshSessionPending(threadKey, true);
setFreshSessionPending(threadKey, true, {
withRecap: isRecapComposerCommand(text),
});
clearComposerDraftContent(threadKey);
return null;
}
Expand Down Expand Up @@ -770,6 +776,8 @@ export function useThreadComposerState(input: {

// A redirect belongs to the approval it answers; the armed session waits for the next message.
const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey);
const contextHandoffOnFresh =
freshContext && getComposerDraftSnapshot(threadKey).contextHandoffOnFresh === true;
const metadata = makeQueuedMessageMetadata();
// A fresh session drops the context a compaction would summarize. Only a send the notice
// announced compacts, since the notice's minute clock can trail the exact deadline.
Expand Down Expand Up @@ -799,6 +807,7 @@ export function useThreadComposerState(input: {
? {}
: { dispatchMode: followUpDispatchMode }),
...(freshContext ? { freshContext: true } : {}),
...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}),
createdAt: metadata.createdAt,
});
clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
Expand All @@ -816,7 +825,9 @@ export function useThreadComposerState(input: {
attachments: [],
});
appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
if (freshContext) setFreshSessionPending(threadKey, true);
if (freshContext) {
setFreshSessionPending(threadKey, true, { withRecap: contextHandoffOnFresh });
}
setThreadComposerError(
threadKey,
error instanceof Error ? error.message : "Failed to save the queued message.",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3142,6 +3142,7 @@ const make = Effect.gen(function* () {
attachments: [],
mode,
...(input.freshContext ? { freshContext: true } : {}),
...(input.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}),
createdBy: "agent",
creationSource: "mcp",
})
Expand Down
Loading
Loading