Organize composer into its own module
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { submitAgentInput } from "./agent-input-submit";
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
}
|
||||
|
||||
describe("submitAgentInput", () => {
|
||||
it("clears the composer before an in-flight submit resolves", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " hello world ",
|
||||
attachments: [],
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "hello world",
|
||||
attachments: [],
|
||||
});
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
await expect(submitPromise).resolves.toBe("submitted");
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("preserves the composer before an in-flight submit resolves when requested", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const attachments = [{ id: "img-1" }];
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " keep me ",
|
||||
attachments,
|
||||
submitBehavior: "preserve-and-lock",
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "keep me",
|
||||
attachments,
|
||||
});
|
||||
expect(setUserInput).not.toHaveBeenCalled();
|
||||
expect(setAttachments).not.toHaveBeenCalled();
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
await expect(submitPromise).resolves.toBe("submitted");
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("queues while the agent is running and clears the composer immediately", async () => {
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn();
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " queued message ",
|
||||
attachments: [{ id: "img-1" }],
|
||||
isAgentRunning: true,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
}),
|
||||
).resolves.toBe("queued");
|
||||
|
||||
expect(queueMessage).toHaveBeenCalledWith({
|
||||
message: "queued message",
|
||||
attachments: [{ id: "img-1" }],
|
||||
});
|
||||
expect(submitMessage).not.toHaveBeenCalled();
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).not.toHaveBeenCalled();
|
||||
expect(setIsProcessing).not.toHaveBeenCalled();
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the composer when submit fails", async () => {
|
||||
const submitError = new Error("No host selected");
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
throw submitError;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
const onSubmitError = vi.fn();
|
||||
const attachments = [{ id: "img-1" }];
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " hello world ",
|
||||
attachments,
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
onSubmitError,
|
||||
}),
|
||||
).resolves.toBe("failed");
|
||||
|
||||
expect(onSubmitError).toHaveBeenCalledWith(submitError);
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(2, attachments);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(1, null);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits when empty submit is explicitly allowed", async () => {
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " ",
|
||||
attachments: [],
|
||||
allowEmptySubmit: true,
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
}),
|
||||
).resolves.toBe("submitted");
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "",
|
||||
attachments: [],
|
||||
});
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||
|
||||
export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
message: string;
|
||||
attachments: TAttachment[];
|
||||
hasExternalContent?: boolean;
|
||||
allowEmptySubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
forceSend?: boolean;
|
||||
isAgentRunning: boolean;
|
||||
canSubmit: boolean;
|
||||
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
|
||||
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
setUserInput: (text: string) => void;
|
||||
setAttachments: (attachments: TAttachment[]) => void;
|
||||
setSendError: (message: string | null) => void;
|
||||
setIsProcessing: (isProcessing: boolean) => void;
|
||||
onSubmitError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export async function submitAgentInput<TAttachment>(
|
||||
input: AgentInputSubmitActionInput<TAttachment>,
|
||||
): Promise<AgentInputSubmitResult> {
|
||||
const trimmedMessage = input.message.trim();
|
||||
const attachments = input.attachments;
|
||||
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
|
||||
|
||||
if (
|
||||
!trimmedMessage &&
|
||||
attachments.length === 0 &&
|
||||
!input.hasExternalContent &&
|
||||
!input.allowEmptySubmit
|
||||
) {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
if (!input.canSubmit) {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
if (input.isAgentRunning && !input.forceSend) {
|
||||
input.queueMessage({ message: trimmedMessage, attachments });
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
input.setSendError(null);
|
||||
input.setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await input.submitMessage({ message: trimmedMessage, attachments });
|
||||
input.clearDraft("sent");
|
||||
input.setIsProcessing(false);
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
}
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
|
||||
|
||||
describe("isProviderModelsQueryLoading", () => {
|
||||
it("does not treat a disabled pending query as loading", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: ["providerModels", "server-1", "__missing_provider__"],
|
||||
enabled: false,
|
||||
queryFn: async () => [],
|
||||
});
|
||||
|
||||
const result = observer.getCurrentResult();
|
||||
|
||||
expect(result.isPending).toBe(true);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.isFetching).toBe(false);
|
||||
expect(isProviderModelsQueryLoading(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an active fetch as loading", () => {
|
||||
expect(
|
||||
isProviderModelsQueryLoading({
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
interface ProviderModelsQueryState {
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
|
||||
return input.isLoading || input.isFetching;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./agent-status-bar.utils";
|
||||
|
||||
describe("getStatusSelectorHint", () => {
|
||||
it("explains what each editable status control does", () => {
|
||||
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
|
||||
expect(getStatusSelectorHint("model")).toBe("Change model");
|
||||
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature metadata helpers", () => {
|
||||
it("prefers explicit feature tooltip copy", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Plan",
|
||||
tooltip: "Toggle plan mode",
|
||||
}),
|
||||
).toBe("Toggle plan mode");
|
||||
});
|
||||
|
||||
it("falls back to the feature label when no tooltip is provided", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Custom",
|
||||
}),
|
||||
).toBe("Custom");
|
||||
});
|
||||
|
||||
it("maps feature highlight colors by feature id", () => {
|
||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
expect(normalizeModelId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trimmed model ids", () => {
|
||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||
expect(normalizeModelId(" default ")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentModelSelection", () => {
|
||||
it("prefers runtime model over configured model", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: "a",
|
||||
configuredModelId: "b",
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.activeModelId).toBe("a");
|
||||
expect(selection.displayModel).toBe("Model A");
|
||||
expect(selection.selectedThinkingId).toBe("low");
|
||||
});
|
||||
|
||||
it("uses explicit thinking option when provided", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: "a",
|
||||
configuredModelId: null,
|
||||
explicitThinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(selection.selectedThinkingId).toBe("high");
|
||||
expect(selection.displayThinking).toBe("High");
|
||||
});
|
||||
|
||||
it("falls back to the provider default model label instead of Auto", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
isDefault: true,
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: null,
|
||||
configuredModelId: null,
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.displayModel).toBe("Model A");
|
||||
expect(selection.displayThinking).toBe("Low");
|
||||
});
|
||||
|
||||
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: "default",
|
||||
provider: "claude",
|
||||
label: "Default (Sonnet 4.6)",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
],
|
||||
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||
configuredModelId: "default",
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.activeModelId).toBe("default");
|
||||
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||
expect(selection.selectedThinkingId).toBe("low");
|
||||
expect(selection.displayThinking).toBe("Low");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
case "thinking":
|
||||
return "Thinking mode";
|
||||
case "model":
|
||||
return "Change model";
|
||||
case "mode":
|
||||
return "Change permission mode";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||
switch (featureId) {
|
||||
case "fast_mode":
|
||||
return "yellow";
|
||||
case "plan_mode":
|
||||
return "blue";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function findModelById(
|
||||
models: AgentModelDefinition[] | null,
|
||||
modelId: string | null,
|
||||
): AgentModelDefinition | null {
|
||||
if (!models || !modelId) {
|
||||
return null;
|
||||
}
|
||||
return models.find((model) => model.id === modelId) ?? null;
|
||||
}
|
||||
|
||||
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
|
||||
return models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvePreferredModelId(
|
||||
runtimeSelectedModel: AgentModelDefinition | null,
|
||||
normalizedConfiguredModelId: string | null,
|
||||
normalizedRuntimeModelId: string | null,
|
||||
): string | null {
|
||||
return runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||
}
|
||||
|
||||
function pickSelectedModel(
|
||||
models: AgentModelDefinition[] | null,
|
||||
preferredModelId: string | null,
|
||||
fallbackModel: AgentModelDefinition | null,
|
||||
): AgentModelDefinition | null {
|
||||
if (!models || !preferredModelId) {
|
||||
return fallbackModel;
|
||||
}
|
||||
return findModelById(models, preferredModelId) ?? fallbackModel;
|
||||
}
|
||||
|
||||
function resolveThinkingId(
|
||||
explicitThinkingOptionId: string | null | undefined,
|
||||
selectedModel: AgentModelDefinition | null,
|
||||
): string | null {
|
||||
if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") {
|
||||
return explicitThinkingOptionId;
|
||||
}
|
||||
return selectedModel?.defaultThinkingOptionId ?? null;
|
||||
}
|
||||
|
||||
type ThinkingOption = NonNullable<AgentModelDefinition["thinkingOptions"]>[number];
|
||||
|
||||
function resolveEffectiveThinking(
|
||||
thinkingOptions: ThinkingOption[] | null,
|
||||
resolvedThinkingId: string | null,
|
||||
): ThinkingOption | null {
|
||||
const selectedThinking =
|
||||
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||
return selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveModelDisplay(
|
||||
selectedModel: AgentModelDefinition | null,
|
||||
preferredModelId: string | null,
|
||||
fallbackModel: AgentModelDefinition | null,
|
||||
): { activeModelId: string | null; displayModel: string } {
|
||||
return {
|
||||
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
|
||||
displayModel:
|
||||
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
models: AgentModelDefinition[] | null;
|
||||
runtimeModelId: string | null | undefined;
|
||||
configuredModelId: string | null | undefined;
|
||||
explicitThinkingOptionId: string | null | undefined;
|
||||
}) {
|
||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||
|
||||
const runtimeSelectedModel = findModelById(models, normalizedRuntimeModelId);
|
||||
const preferredModelId = resolvePreferredModelId(
|
||||
runtimeSelectedModel,
|
||||
normalizedConfiguredModelId,
|
||||
normalizedRuntimeModelId,
|
||||
);
|
||||
const fallbackModel = getFallbackModel(models);
|
||||
const selectedModel = pickSelectedModel(models, preferredModelId, fallbackModel);
|
||||
|
||||
const { activeModelId, displayModel } = resolveModelDisplay(
|
||||
selectedModel,
|
||||
preferredModelId,
|
||||
fallbackModel,
|
||||
);
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
|
||||
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
|
||||
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
activeModelId,
|
||||
displayModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingId,
|
||||
displayThinking,
|
||||
};
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "./composer-actions";
|
||||
|
||||
const imageMetadata: AttachmentMetadata = {
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "img-1",
|
||||
fileName: "img-1.png",
|
||||
byteSize: 42,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const issueItem: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 101,
|
||||
title: "Fix composer attachments",
|
||||
url: "https://github.com/acme/paseo/issues/101",
|
||||
state: "open",
|
||||
body: "Issue body",
|
||||
labels: ["composer"],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
const prItem: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
state: "open",
|
||||
body: "PR body",
|
||||
labels: ["composer"],
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
};
|
||||
|
||||
function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body,
|
||||
context: {
|
||||
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: `review:${body}`,
|
||||
commentCount: 1,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementWorkspaceAttachment(): Extract<
|
||||
WorkspaceComposerAttachment,
|
||||
{ kind: "browser_element" }
|
||||
> {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com/page",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Save",
|
||||
outerHTML: '<button class="primary">Save</button>',
|
||||
computedStyles: { display: "flex" },
|
||||
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||
reactSource: null,
|
||||
parentChain: ["form.settings"],
|
||||
children: [],
|
||||
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePersister(): AttachmentPersister & {
|
||||
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||
deletedBatches: AttachmentMetadata[][];
|
||||
} {
|
||||
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||
const deletedBatches: AttachmentMetadata[][] = [];
|
||||
return {
|
||||
blobCalls,
|
||||
fileUriCalls,
|
||||
deletedBatches,
|
||||
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||
blobCalls.push({ blob, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||
},
|
||||
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||
fileUriCalls.push({ uri, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||
},
|
||||
deleteAttachments: (metadata) => {
|
||||
deletedBatches.push(metadata);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeSendCall {
|
||||
agentId: string;
|
||||
text: string;
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: AgentAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
if (options.rejection) {
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
|
||||
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getTail: (agentId) => fake.tail.get(agentId),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||
state: new Map(initial),
|
||||
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||
write: (updater) => {
|
||||
fake.state = updater(fake.state);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||
|
||||
describe("cancelComposerAgent", () => {
|
||||
function baseInput(): {
|
||||
client: ComposerCancelClient & { canceledIds: string[] };
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
} {
|
||||
const canceledIds: string[] = [];
|
||||
return {
|
||||
client: {
|
||||
canceledIds,
|
||||
cancelAgent: async (id) => {
|
||||
canceledIds.push(id);
|
||||
},
|
||||
},
|
||||
agentId: "agent",
|
||||
isAgentRunning: true,
|
||||
isCancellingAgent: false,
|
||||
isConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent(input);
|
||||
expect(result).toBe(true);
|
||||
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is not running", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is already being canceled", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when disconnected or the client is null", () => {
|
||||
const input = baseInput();
|
||||
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAndPersistImages", () => {
|
||||
it("returns [] when the picker yields nothing", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => null,
|
||||
persister,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
expect(persister.blobCalls).toEqual([]);
|
||||
expect(persister.fileUriCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||
const persister = createFakePersister();
|
||||
const blob = new Blob(["image"]);
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||
});
|
||||
|
||||
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.fileUriCalls).toEqual([
|
||||
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const image = imageWithId("img-2");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "send attachments",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
const [call] = client.calls;
|
||||
expect(call.agentId).toBe("agent");
|
||||
expect(call.text).toBe("send attachments");
|
||||
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||
expect(call.options.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
expect(userMessage.kind).toBe("user_message");
|
||||
expect(userMessage.text).toBe("send attachments");
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
const existingItem: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "prior",
|
||||
text: "prior",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||
const client = createFakeSendClient();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
images: [],
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
expect(client.calls[0]?.options.images).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const browserElement = browserElementWorkspaceAttachment();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Browser element · button",
|
||||
text: browserElement.attachment.formatted,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueComposerMessage", () => {
|
||||
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " draft ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(result.queued?.text).toBe("draft");
|
||||
expect(queue.state.get("agent")).toEqual([
|
||||
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue an empty message with no attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
expect(result.queued).toBeNull();
|
||||
expect(queue.state.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||
const image = imageWithId("img-queue");
|
||||
queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: "queue this",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||
{ kind: "image", metadata: image },
|
||||
review,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editQueuedComposerMessage", () => {
|
||||
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||
);
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||
expect(result).toBeNull();
|
||||
expect(queue.state.get("agent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||
const image = imageWithId("img-queued-edit");
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||
expect(result).toEqual({
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
});
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendQueuedComposerMessageNow", () => {
|
||||
it("returns missing without submitting when the message id is gone", async () => {
|
||||
const queue = createFakeQueue();
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "missing" });
|
||||
expect(submitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the queued entry and submits its text + attachments", async () => {
|
||||
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||
);
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "submitted" });
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||
});
|
||||
|
||||
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{ id: "msg-1", text: "first", attachments: [] },
|
||||
{ id: "msg-2", text: "second", attachments: [] },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||
const state = queue.state.get("agent");
|
||||
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeComposerAttachmentAtIndex", () => {
|
||||
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||
const image = imageWithId("img-remove");
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([]);
|
||||
expect(persister.deletedBatches).toEqual([[image]]);
|
||||
});
|
||||
|
||||
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
expect(persister.deletedBatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openComposerAttachment", () => {
|
||||
it("opens the lightbox for image attachments", () => {
|
||||
const image = imageWithId("img-body");
|
||||
const lightboxCalls: AttachmentMetadata[] = [];
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "image", metadata: image },
|
||||
setLightboxMetadata: (metadata) => {
|
||||
lightboxCalls.push(metadata);
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(lightboxCalls).toEqual([image]);
|
||||
expect(externalUrlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("delegates workspace review attachments to the workspace opener", () => {
|
||||
const review = reviewWorkspaceAttachment("Open me.");
|
||||
const workspaceCalls: ComposerAttachment[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: review,
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: ({ attachment }) => {
|
||||
workspaceCalls.push(attachment);
|
||||
return true;
|
||||
},
|
||||
openExternalUrl: () => {
|
||||
throw new Error("unexpected external url call");
|
||||
},
|
||||
});
|
||||
expect(workspaceCalls).toEqual([review]);
|
||||
});
|
||||
|
||||
it("opens GitHub item URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "github_issue", item: issueItem },
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
it("appends a GitHub issue when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], issueItem);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
});
|
||||
|
||||
it("appends a GitHub PR when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], prItem);
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("removes an existing GitHub item with the same kind+number", () => {
|
||||
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not affect other items with different kind or number", () => {
|
||||
const start: UserComposerAttachment[] = [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
];
|
||||
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||
const next = toggleGithubAttachment(start, otherIssue);
|
||||
expect(next).toEqual([
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
{ kind: "github_issue", item: otherIssue },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachmentFromPicker", () => {
|
||||
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
|
||||
const markGithubAttachmentRemoved = vi.fn();
|
||||
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
|
||||
|
||||
const next = toggleGithubAttachmentFromPicker({
|
||||
current: [attachment],
|
||||
item: prItem,
|
||||
markGithubAttachmentRemoved,
|
||||
});
|
||||
|
||||
expect(next).toEqual([]);
|
||||
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
|
||||
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
|
||||
});
|
||||
|
||||
it("does not mark a GitHub item removed when picker toggle adds it", () => {
|
||||
const markGithubAttachmentRemoved = vi.fn();
|
||||
|
||||
const next = toggleGithubAttachmentFromPicker({
|
||||
current: [],
|
||||
item: issueItem,
|
||||
markGithubAttachmentRemoved,
|
||||
});
|
||||
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||
const attachments: ComposerAttachment[] = [
|
||||
{ kind: "image", metadata: imageWithId("img-x") },
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
reviewWorkspaceAttachment("ignored"),
|
||||
];
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import {
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
|
||||
export interface AttachmentPersister {
|
||||
persistFromBlob: (input: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
persistFromFileUri: (input: {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComposerSendClient {
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getTail: (agentId: string) => StreamItem[] | undefined;
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
read: (agentId: string) => QueuedComposerMessage[];
|
||||
write: (
|
||||
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export async function pickAndPersistImages(input: {
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||
}): Promise<AttachmentMetadata[]> {
|
||||
const result = await input.pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (picked) => {
|
||||
const fileName = picked.fileName ?? null;
|
||||
const mimeType = picked.mimeType || "image/jpeg";
|
||||
if (picked.source.kind === "blob") {
|
||||
return await input.persister.persistFromBlob({
|
||||
blob: picked.source.blob,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
return await input.persister.persistFromFileUri({
|
||||
uri: picked.source.uri,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||
attachments: T[];
|
||||
index: number;
|
||||
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||
}): T[] {
|
||||
const removed = input.attachments[input.index];
|
||||
if (removed?.kind === "image") {
|
||||
void input.deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return input.attachments.filter((_, i) => i !== input.index);
|
||||
}
|
||||
|
||||
export interface CancelComposerAgentInput {
|
||||
client: ComposerCancelClient | null;
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||
if (!input.isConnected || !input.client) return false;
|
||||
void input.client.cancelAgent(input.agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchComposerAgentMessageInput {
|
||||
client: ComposerSendClient;
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const messageId = generateMessageId();
|
||||
const userMessage = buildOptimisticUserMessage({
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: UserMessageItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
const result = appendOptimisticUserMessageToStream({
|
||||
tail: stream.getTail(agentId) ?? [],
|
||||
head: stream.getHead(agentId) ?? [],
|
||||
message: userMessage,
|
||||
placement: "active-head",
|
||||
});
|
||||
if (result.changedHead) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.head);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (result.changedTail) {
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.tail);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageResult {
|
||||
queued: QueuedComposerMessage | null;
|
||||
}
|
||||
|
||||
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed && input.attachments.length === 0) {
|
||||
return { queued: null };
|
||||
}
|
||||
const item: QueuedComposerMessage = {
|
||||
id: generateMessageId(),
|
||||
text: trimmed,
|
||||
attachments: input.attachments,
|
||||
};
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||
return next;
|
||||
});
|
||||
return { queued: item };
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageResult {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
}
|
||||
|
||||
export function editQueuedComposerMessage(
|
||||
input: EditQueuedComposerMessageInput,
|
||||
): EditQueuedComposerMessageResult | null {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return null;
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
text: item.text,
|
||||
attachments: userAttachmentsOnly(item.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendQueuedComposerMessageNowInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
| { status: "missing" }
|
||||
| { status: "submitted" }
|
||||
| { status: "failed"; errorMessage: string };
|
||||
|
||||
export async function sendQueuedComposerMessageNow(
|
||||
input: SendQueuedComposerMessageNowInput,
|
||||
): Promise<SendQueuedComposerMessageNowResult> {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return { status: "missing" };
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||
return { status: "submitted" };
|
||||
} catch (error) {
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenComposerAttachmentInput {
|
||||
attachment: ComposerAttachment;
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||
openExternalUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||
if (input.attachment.kind === "image") {
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||
return;
|
||||
}
|
||||
input.openExternalUrl(input.attachment.item.url);
|
||||
}
|
||||
|
||||
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface ToggleGithubAttachmentFromPickerInput {
|
||||
current: UserComposerAttachment[];
|
||||
item: GitHubSearchItem;
|
||||
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
|
||||
}
|
||||
|
||||
export function toggleGithubAttachmentFromPicker({
|
||||
current,
|
||||
item,
|
||||
markGithubAttachmentRemoved,
|
||||
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
|
||||
const existingAttachment = current.find(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
if (existingAttachment) {
|
||||
markGithubAttachmentRemoved(existingAttachment);
|
||||
}
|
||||
return toggleGithubAttachment(current, item);
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
export function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
|
||||
images: ImageAttachment[];
|
||||
attachments: AgentAttachment[];
|
||||
} {
|
||||
const images: ImageAttachment[] = [];
|
||||
const reviewAttachments: AgentAttachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.kind === "image") {
|
||||
images.push(attachment.metadata);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
reviewAttachments.push(workspaceAttachment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
||||
if (reviewAttachment) {
|
||||
reviewAttachments.push(reviewAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
images,
|
||||
attachments: reviewAttachments,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./composer-height-mirror.native";
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.visibility = "hidden";
|
||||
style.pointerEvents = "none";
|
||||
style.overflow = "hidden";
|
||||
style.border = "0";
|
||||
style.margin = "0";
|
||||
style.resize = "none";
|
||||
style.zIndex = "-1";
|
||||
style.boxSizing = "border-box";
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const {
|
||||
value: currentValue,
|
||||
minHeight: currentMinHeight,
|
||||
maxHeight: currentMaxHeight,
|
||||
onHeight: currentOnHeight,
|
||||
} = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = currentValue.endsWith("\n") ? `${currentValue} ` : currentValue;
|
||||
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
}, [textareaRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
let previousWidth = source.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure();
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
expect(resolveStatusControlMode(undefined)).toBe("ready");
|
||||
});
|
||||
|
||||
it("uses draft mode when controlled status controls are provided", () => {
|
||||
expect(
|
||||
resolveStatusControlMode({
|
||||
providerDefinitions: [],
|
||||
selectedProvider: "codex",
|
||||
onSelectProvider: () => undefined,
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
|
||||
|
||||
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
|
||||
return statusControls ? "draft" : "ready";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "./message-input";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeCanStartDictation } from "./message-input-state";
|
||||
|
||||
const connected = { isConnected: true } as never;
|
||||
const disconnected = { isConnected: false } as never;
|
||||
|
||||
describe("computeCanStartDictation", () => {
|
||||
it("returns false when socket is disconnected", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: disconnected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when isReadyForDictation is explicitly false", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: false,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when connected and ready", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: undefined,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: disconnected,
|
||||
isReadyForDictation: undefined,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the input is disabled", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: true,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when a dictation unavailable message is present", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: "Microphone unavailable",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when client is null", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: null,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
|
||||
export function computeCanStartDictation(input: {
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
disabled: boolean;
|
||||
dictationUnavailableMessage: string | null | undefined;
|
||||
}): boolean {
|
||||
const socketConnected = input.client?.isConnected ?? false;
|
||||
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
||||
return (
|
||||
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { GitHubSearchClient } from "@/git/use-github-search-query";
|
||||
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
|
||||
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
|
||||
|
||||
type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
||||
|
||||
const remoteUrl = "git@github.com:acme/paseo.git";
|
||||
const cwd = "/repo";
|
||||
|
||||
const pr101: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 101,
|
||||
title: "Attach PR",
|
||||
url: "https://github.com/acme/paseo/pull/101",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
};
|
||||
|
||||
const issue202: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 202,
|
||||
title: "Attach issue",
|
||||
url: "https://github.com/acme/paseo/issues/202",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
interface SearchCall {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface HarnessInput {
|
||||
initialAttachments?: UserComposerAttachment[];
|
||||
initialText?: string;
|
||||
remote?: string | null;
|
||||
}
|
||||
|
||||
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
|
||||
return {
|
||||
items,
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearchClient(
|
||||
items: GitHubSearchItem[],
|
||||
): GitHubSearchClient & { calls: SearchCall[] } {
|
||||
const calls: SearchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
async searchGitHub(options) {
|
||||
calls.push(options);
|
||||
return githubPayload(items, `search-${options.query}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
|
||||
const [text, setText] = useState(input.initialText ?? "");
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
);
|
||||
const autoAttach = useComposerGithubAutoAttach({
|
||||
text,
|
||||
remoteUrl: input.remote ?? remoteUrl,
|
||||
attachments,
|
||||
client,
|
||||
isConnected: true,
|
||||
serverId: "server-1",
|
||||
cwd,
|
||||
setAttachments,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushDebounce() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useComposerGithubAutoAttach", () => {
|
||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
|
||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores URLs that do not match the current remote", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not add a second pill when the ref is already attached", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
|
||||
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual(initialAttachments);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
|
||||
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
|
||||
result.current.setAttachments([]);
|
||||
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles multiple matching URLs from one paste", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101, issue202]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText(
|
||||
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
|
||||
);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([
|
||||
{ kind: "github_pr", item: pr101 },
|
||||
{ kind: "github_issue", item: issue202 },
|
||||
]);
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd, query: "101", limit: 20 },
|
||||
{ cwd, query: "202", limit: 20 },
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
buildGithubSearchQueryOptions,
|
||||
type GitHubSearchClient,
|
||||
} from "@/git/use-github-search-query";
|
||||
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
|
||||
|
||||
const AUTO_ATTACH_DEBOUNCE_MS = 300;
|
||||
|
||||
interface ComposerGithubAutoAttachInput {
|
||||
text: string;
|
||||
remoteUrl: string | null | undefined;
|
||||
attachments: UserComposerAttachment[];
|
||||
client: GitHubSearchClient | null;
|
||||
isConnected: boolean;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
}
|
||||
|
||||
interface ComposerGithubAutoAttachResult {
|
||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||
}
|
||||
|
||||
export function useComposerGithubAutoAttach(
|
||||
params: ComposerGithubAutoAttachInput,
|
||||
): ComposerGithubAutoAttachResult {
|
||||
const queryClient = useQueryClient();
|
||||
const latestRef = useRef(params);
|
||||
const removedRefKeysRef = useRef(new Set<string>());
|
||||
const pendingRefKeysRef = useRef(new Set<string>());
|
||||
|
||||
latestRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
const refs = refsReadyForLookup({
|
||||
params: latestRef.current,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
});
|
||||
if (refs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
void attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
});
|
||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [
|
||||
params.text,
|
||||
params.remoteUrl,
|
||||
params.attachments,
|
||||
params.client,
|
||||
params.isConnected,
|
||||
params.serverId,
|
||||
params.cwd,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
|
||||
const key = attachmentKey(attachment);
|
||||
if (key) {
|
||||
removedRefKeysRef.current.add(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
markGithubAttachmentRemoved,
|
||||
}),
|
||||
[markGithubAttachmentRemoved],
|
||||
);
|
||||
}
|
||||
|
||||
async function attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys,
|
||||
pendingRefKeys,
|
||||
}: {
|
||||
refs: GithubRef[];
|
||||
queryClient: QueryClient;
|
||||
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||
removedRefKeys: Set<string>;
|
||||
pendingRefKeys: Set<string>;
|
||||
}): Promise<void> {
|
||||
for (const ref of refs) {
|
||||
const key = githubRefKey(ref);
|
||||
if (pendingRefKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
pendingRefKeys.add(key);
|
||||
try {
|
||||
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
|
||||
} finally {
|
||||
pendingRefKeys.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attachRef({
|
||||
ref,
|
||||
key,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys,
|
||||
}: {
|
||||
ref: GithubRef;
|
||||
key: string;
|
||||
queryClient: QueryClient;
|
||||
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||
removedRefKeys: Set<string>;
|
||||
}): Promise<void> {
|
||||
const snapshot = latestRef.current;
|
||||
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestRef.current.setAttachments((current) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
||||
return current;
|
||||
}
|
||||
return toggleGithubAttachment(current, item);
|
||||
});
|
||||
}
|
||||
|
||||
function refsReadyForLookup({
|
||||
params,
|
||||
removedRefKeys,
|
||||
pendingRefKeys,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
removedRefKeys: Set<string>;
|
||||
pendingRefKeys: Set<string>;
|
||||
}): GithubRef[] {
|
||||
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
|
||||
const key = githubRefKey(ref);
|
||||
return (
|
||||
!removedRefKeys.has(key) &&
|
||||
!pendingRefKeys.has(key) &&
|
||||
!hasGithubAttachment(params.attachments, ref)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchGithubRefSearch({
|
||||
ref,
|
||||
snapshot,
|
||||
queryClient,
|
||||
}: {
|
||||
ref: GithubRef;
|
||||
snapshot: ComposerGithubAutoAttachInput;
|
||||
queryClient: QueryClient;
|
||||
}) {
|
||||
if (!snapshot.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await queryClient.fetchQuery(
|
||||
buildGithubSearchQueryOptions({
|
||||
client: snapshot.client,
|
||||
serverId: snapshot.serverId,
|
||||
cwd: snapshot.cwd,
|
||||
query: String(ref.number),
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
|
||||
return extractGithubRefs(params.text, params.remoteUrl).some(
|
||||
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
|
||||
);
|
||||
}
|
||||
|
||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
|
||||
return item.kind === githubItemKind(ref) && item.number === ref.number;
|
||||
}
|
||||
|
||||
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
|
||||
return ref.kind === "pull" ? "pr" : "issue";
|
||||
}
|
||||
|
||||
function githubRefKey(ref: GithubRef): string {
|
||||
return `${githubItemKind(ref)}:${ref.number}`;
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
|
||||
if (
|
||||
!attachment ||
|
||||
attachment.kind === "image" ||
|
||||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${attachment.item.kind}:${attachment.item.number}`;
|
||||
}
|
||||
@@ -3,23 +3,25 @@ import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { Composer } from "@/composer";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "./message-input";
|
||||
import type { ImageAttachment, MessagePayload } from "@/composer/types";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -347,18 +349,27 @@ export function WorkspaceSetupDialog() {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
|
||||
const statusControlsWithDisabled = useMemo(
|
||||
const agentControlsWithDisabled = useMemo(
|
||||
() =>
|
||||
composerState
|
||||
? {
|
||||
...composerState.statusControls,
|
||||
...composerState.agentControls,
|
||||
disabled: pendingAction !== null,
|
||||
}
|
||||
: undefined,
|
||||
[composerState, pendingAction],
|
||||
);
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact && agentControlsWithDisabled ? (
|
||||
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
||||
) : undefined,
|
||||
[isCompact, agentControlsWithDisabled],
|
||||
);
|
||||
|
||||
const subtitleContent = useMemo(
|
||||
() => (
|
||||
<View style={styles.subtitleRow}>
|
||||
@@ -412,9 +423,10 @@ export function WorkspaceSetupDialog() {
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
footer={composerFooter}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user