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
19 changes: 16 additions & 3 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function ComposerEditor({
retained.attachments.length,
controller.signal,
retained.context?.records.length ?? 0,
environmentId,
);
if (!result) {
insertComposerDraftText(draftKey, clipboard.text, insertion);
Expand All @@ -113,10 +114,22 @@ export function ComposerEditor({
);
return;
}
if (result.failures.length > 0)
const warnings = [
...(result.failures.length > 0
? [
"Some attachments could not be copied. Reconnect to the source environment and copy them again.",
]
: []),
...(result.skippedThreads.length > 0
? [
`Threads from another machine weren't pasted: this machine's agent can't read ${result.skippedThreads.map((title) => `"${title}"`).join(", ")}.`,
]
: []),
];
if (warnings.length > 0)
Alert.alert(
"Some attachments could not be copied",
"Reconnect to the source environment and copy them again. References without their files are marked unavailable.",
"Some context could not be pasted",
[...warnings, "Those references are marked unavailable."].join("\n\n"),
);
} catch (error) {
if (!controller.signal.aborted)
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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 { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { useComposerCommandMenu } from "./use-composer-command-menu";
Expand Down Expand Up @@ -1290,6 +1291,22 @@ export function NewTaskDraftScreen(props: {
Alert.alert("Too much context", contextBlockReason);
return;
}
const strandedThread = threadContextsOutsideEnvironment({
text: draft.text,
records: draft.context?.records ?? [],
environmentId: selectedProject.environmentId,
})[0];
if (strandedThread) {
const owner =
flow.environments.find(
(environment) => environment.environmentId === strandedThread.environmentId,
)?.environmentLabel ?? "its machine";
Alert.alert(
"Attached thread is on another machine",
`This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`,
);
return;
}

const editingPendingTask = flow.editingPendingTask;

Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { sanitizeNewRefName } from "@t3tools/shared/git";
import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import * as Arr from "effect/Array";
Expand Down Expand Up @@ -1108,6 +1109,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (text.length === 0 || !draftModelSelection) {
return null;
}
// A thread attached on another machine is unreadable here; the submit path explains why.
if (
threadContextsOutsideEnvironment({
text: draft.text,
records: draft.context?.records ?? [],
environmentId: selectedProject.environmentId,
}).length > 0
) {
return null;
}
// A saved choice from before the project went no-project must not
// survive: those threads always run locally in their own folder.
const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined;
Expand Down
37 changes: 36 additions & 1 deletion apps/mobile/src/lib/composerContextClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { ComposerContextId, EnvironmentId } from "@t3tools/contracts";
import {
COMPOSER_CONTEXT_MAX_RECORDS,
ComposerContextId,
EnvironmentId,
ThreadId,
} from "@t3tools/contracts";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -142,6 +147,36 @@ describe("mobile context clipboard imports", () => {
expect(mocks.download).not.toHaveBeenCalled();
});

it("drops threads owned by another machine than the destination", async () => {
const thread = (id: string, environmentId: string) => ({
version: 1 as const,
kind: "thread" as const,
contextId: ComposerContextId.make(id),
label: id,
environmentId: EnvironmentId.make(environmentId),
threadId: ThreadId.make(id),
title: id,
});
const result = await importComposerContextClipboard(
{
text: "[local](t3-context://v1/thread/local) [foreign](t3-context://v1/thread/foreign)",
fragment: encodeComposerContextFragment({
version: 1,
source: { environmentId: EnvironmentId.make("source") },
records: [thread("local", "destination"), thread("foreign", "source")],
})!,
html: "",
},
0,
new AbortController().signal,
// Only one slot is free: the dropped thread must not count against it.
COMPOSER_CONTEXT_MAX_RECORDS - 1,
EnvironmentId.make("destination"),
);
expect(result?.context.records.map((record) => record.label)).toEqual(["local"]);
expect(result?.skippedThreads).toEqual(["foreign"]);
});

it("refuses an overflowing context paste before copying files", async () => {
await expect(
importComposerContextClipboard(clipboard, 0, new AbortController().signal, 200),
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/lib/composerContextClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,28 @@ export async function importComposerContextClipboard(
existingCount: number,
signal: AbortSignal,
existingContextCount = 0,
destinationEnvironmentId?: EnvironmentId,
) {
const fragment =
decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html);
if (!fragment) return null;
const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records });
if (existingContextCount + (selected?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS)
// The agent can only read threads on its own server; a pasted foreign one is dropped and its
// reference stays visibly unavailable, like an attachment that failed to copy.
const skippedThreads: string[] = [];
const retained = (selected?.records ?? []).filter((record) => {
if (
record.kind !== "thread" ||
!("threadId" in record) ||
record.environmentId === destinationEnvironmentId
)
return true;
skippedThreads.push(record.title);
return false;
});
if (existingContextCount + retained.length > COMPOSER_CONTEXT_MAX_RECORDS)
throw new Error("Remove some context items from the draft before pasting more.");
const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4);
const imported = reidentifyComposerContext(input.text, retained, uuidv4);
const attachments: DraftComposerAttachment[] = [];
const records: ComposerContextRecord[] = [];
const failures: string[] = [];
Expand All @@ -100,6 +114,7 @@ export async function importComposerContextClipboard(
context: { version: 1 as const, records },
attachments,
failures,
skippedThreads,
};
} catch (error) {
await Promise.all(
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { feedbackBannerItem } from "./chat/ComposerFeedback";
import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
import * as Schema from "effect/Schema";
import {
questionAttachmentDraftId,
Expand Down Expand Up @@ -1805,6 +1806,9 @@ export default function ChatView(props: ChatViewProps) {
const draft = store.getComposerDraft(composerDraftTarget);
return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
});
const draftThreadContexts = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.threadContexts ?? null,
);
// Anything beyond the prompt text: attachments, terminal or element contexts, annotations.
const composerHasNonPromptContent = useComposerDraftStore((store) => {
const draft = store.getComposerDraft(composerDraftTarget);
Expand Down Expand Up @@ -4351,6 +4355,10 @@ export default function ChatView(props: ChatViewProps) {
return (
environment?.connection.phase === "connected" &&
(loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 &&
// Only a machine that owns every attached thread can read them.
(draftThreadContexts ?? []).every(
(record) => record.environmentId === candidate.environmentId,
) &&
environment.serverConfig?.providers.some(
(provider) =>
(activeProviderInstanceId === null ||
Expand All @@ -4370,6 +4378,7 @@ export default function ChatView(props: ChatViewProps) {
needsLoadBalancing,
logicalProjectEnvironments,
environmentById,
draftThreadContexts,
loadBalancingSettings.loadBalancingWeights,
activeProviderInstanceId,
selectedProvider,
Expand Down Expand Up @@ -8790,7 +8799,14 @@ export default function ChatView(props: ChatViewProps) {
: "Choose a machine to continue",
description: loadBalancing.pending
? "Resource checks are still running. You can choose a machine in the composer."
: "No eligible machine has available resources. Choose a machine in the composer to override.",
: draftThreadContexts?.length &&
!logicalProjectEnvironments.some((candidate) =>
draftThreadContexts.every(
(record) => record.environmentId === candidate.environmentId,
),
)
? "No machine can read every attached thread. Choose a machine in the composer or remove the threads."
: "No eligible machine has available resources. Choose a machine in the composer to override.",
});
return;
}
Expand Down Expand Up @@ -8900,6 +8916,22 @@ export default function ChatView(props: ChatViewProps) {
previewAnnotationContextReference(directAnnotation.annotation),
])
: promptRef.current;
const strandedThread = threadContextsOutsideEnvironment({
text: promptForSend,
records: composerThreadContexts,
environmentId,
})[0];
if (strandedThread) {
const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine";
toastManager.add(
stackedThreadToast({
type: "warning",
title: "Attached thread is on another machine",
description: `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`,
}),
);
return;
}
if (editingQueuedRun !== null) {
// Edit mode repurposes the composer: sending saves the queued message
// in place instead of dispatching a new turn.
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/ThreadContextChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
/**
* Inline chip for an attached thread, in the composer and in sent messages. Prefers the
* live title so a renamed thread never shows a stale label, and opens the thread on click.
* `unreadable` marks a composer chip whose thread lives on another machine than the draft.
*/
export function ThreadContextChip(props: {
record: Pick<ThreadContextRecord, "environmentId" | "threadId" | "title">;
copyMarkdown?: string;
unreadable?: boolean;
}) {
const { environmentId, threadId } = props.record;
const shell = useThreadShell({ environmentId, threadId });
Expand All @@ -23,6 +25,7 @@ export function ThreadContextChip(props: {
render={
<ContextChip
kind="thread"
{...(props.unreadable ? { state: "invalid" as const } : {})}
render={<Link to="/$environmentId/$threadId" params={{ environmentId, threadId }} />}
aria-label={`Thread, ${title}`}
data-markdown-copy={props.copyMarkdown}
Expand All @@ -33,7 +36,13 @@ export function ThreadContextChip(props: {
</ContextChip>
}
/>
<TooltipPopup side="top">{shell ? "Open thread" : "Thread no longer available"}</TooltipPopup>
<TooltipPopup side="top">
{props.unreadable
? "On another machine, so this machine's agent can't read it. Remove it or switch back to send."
: shell
? "Open thread"
: "Thread no longer available"}
</TooltipPopup>
</Tooltip>
);
}
13 changes: 12 additions & 1 deletion apps/web/src/components/composerContextPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ function ComposerPreviewAnnotationDetails({
);
}

/** Sending is blocked while an attached thread lives on another machine than the draft. */
function ComposerThreadContextChip(props: { record: ThreadContextRecord }) {
const { environmentId } = use(ComposerContextActionsContext);
return (
<ThreadContextChip
record={props.record}
unreadable={environmentId !== null && props.record.environmentId !== environmentId}
/>
);
}

function UnresolvedContextChip(props: { label: string }) {
return (
<UnresolvedChip
Expand Down Expand Up @@ -438,7 +449,7 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
canRender: (entry) => entry.kind === "thread",
render: (entry, context) =>
entry.kind === "thread" ? (
<ThreadContextChip record={entry.record} />
<ComposerThreadContextChip record={entry.record} />
) : (
<UnresolvedContextChip label={context.label} />
),
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/composerContextReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
projectComposerContextForProvider,
replaceComposerContextReferences,
sanitizeComposerContextLabel,
threadContextsOutsideEnvironment,
} from "./composerContextReferences.ts";

const ctx = (value: string) => value as ComposerContextId;
Expand Down Expand Up @@ -286,3 +287,35 @@ describe("provider projection", () => {
expect(projected).not.toContain("boom");
});
});

describe("threadContextsOutsideEnvironment", () => {
const thread = (id: string, environmentId: string): ComposerContextRecord => ({
version: 1,
kind: "thread",
contextId: ctx(`thread_${id}`),
label: id,
environmentId: environmentId as never,
threadId: id as never,
title: id,
});
const text = "[a](t3-context://v1/thread/thread_a) [b](t3-context://v1/thread/thread_b)";

it("returns referenced threads owned by another environment", () => {
const stranded = threadContextsOutsideEnvironment({
text,
records: [thread("a", "env-a"), thread("b", "env-b")],
environmentId: "env-b" as never,
});
expect(stranded.map((record) => record.threadId)).toEqual(["a"]);
});

it("ignores records whose chip was removed from the text", () => {
expect(
threadContextsOutsideEnvironment({
text: "no references left",
records: [thread("a", "env-a")],
environmentId: "env-b" as never,
}),
).toEqual([]);
});
});
23 changes: 23 additions & 0 deletions packages/shared/src/composerContextReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
type ComposerContextKind,
type ComposerContextRecord,
type ElementContextDetails,
type EnvironmentId,
type KnownComposerContextRecord,
type ThreadContextRecord,
} from "@t3tools/contracts";

/**
Expand Down Expand Up @@ -297,6 +299,27 @@ export function projectComposerContextForProvider(input: {
return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n</${CONTEXT_ENVELOPE_TAG}>`;
}

/**
* Attached threads an agent on `environmentId` cannot read: `t3_thread_read` only sees its own
* server's threads, so a draft moved to another machine strands these references.
*/
export function threadContextsOutsideEnvironment(input: {
text: string;
records: ReadonlyArray<ComposerContextRecord>;
environmentId: EnvironmentId;
}): ThreadContextRecord[] {
const referenced = new Set(
collectComposerContextReferences(input.text).map((occurrence) => occurrence.contextId),
);
return input.records.filter(
(record): record is ThreadContextRecord =>
record.kind === "thread" &&
"threadId" in record &&
referenced.has(record.contextId) &&
record.environmentId !== input.environmentId,
);
}

/** Preserve context bindings when uploads become thread-owned attachments. */
export function remapComposerContextAttachments(
context: import("@t3tools/contracts").OrchestrationMessageContext | undefined,
Expand Down
Loading