Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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 @@ -4359,6 +4363,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,
) &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
environment.serverConfig?.providers.some(
(provider) =>
(activeProviderInstanceId === null ||
Expand All @@ -4385,6 +4393,7 @@ export default function ChatView(props: ChatViewProps) {
needsLoadBalancing,
logicalProjectEnvironments,
environmentById,
draftThreadContexts,
loadBalancingSettings.loadBalancingWeights,
activeProviderInstanceId,
selectedProvider,
Expand Down Expand Up @@ -8850,7 +8859,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 @@ -8960,6 +8976,22 @@ export default function ChatView(props: ChatViewProps) {
previewAnnotationContextReference(directAnnotation.annotation),
])
: promptRef.current;
const strandedThread = threadContextsOutsideEnvironment({
text: promptForSend,
records: composerThreadContexts,
environmentId,
})[0];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 @@ -330,6 +330,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 @@ -439,7 +450,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 @@ -11,6 +11,7 @@ import {
projectComposerContextForProvider,
replaceComposerContextReferences,
sanitizeComposerContextLabel,
threadContextsOutsideEnvironment,
} from "./composerContextReferences.ts";

const ctx = (value: string) => value as ComposerContextId;
Expand Down Expand Up @@ -298,3 +299,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 @@ -311,6 +313,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