mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
feat(ui): add images and model selection to new sessions (#107358)
Merged via squash.
Prepared head SHA: cf99b5bab5
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
This commit is contained in:
@@ -4524,6 +4524,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let emitcommandhooks: Bool?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let worktree: Bool?
|
||||
public let worktreebaseref: String?
|
||||
public let worktreename: String?
|
||||
@@ -4541,6 +4542,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
emitcommandhooks: Bool? = nil,
|
||||
task: String? = nil,
|
||||
message: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
worktree: Bool? = nil,
|
||||
worktreebaseref: String? = nil,
|
||||
worktreename: String? = nil,
|
||||
@@ -4557,6 +4559,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.emitcommandhooks = emitcommandhooks
|
||||
self.task = task
|
||||
self.message = message
|
||||
self.attachments = attachments
|
||||
self.worktree = worktree
|
||||
self.worktreebaseref = worktreebaseref
|
||||
self.worktreename = worktreename
|
||||
@@ -4575,6 +4578,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case emitcommandhooks = "emitCommandHooks"
|
||||
case task
|
||||
case message
|
||||
case attachments
|
||||
case worktree
|
||||
case worktreebaseref = "worktreeBaseRef"
|
||||
case worktreename = "worktreeName"
|
||||
|
||||
@@ -82,6 +82,9 @@ export const ChatMessageGetResultSchema = closedObject({
|
||||
/** Typed result shape for callers that branch on message availability. */
|
||||
export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
|
||||
|
||||
/** Attachment envelope shared by chat.send and session creation's initial turn. */
|
||||
export const ChatAttachmentsSchema = Type.Array(Type.Unknown());
|
||||
|
||||
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
|
||||
export const ChatSendParamsSchema = closedObject({
|
||||
sessionKey: ChatSendSessionKeyString,
|
||||
@@ -97,7 +100,7 @@ export const ChatSendParamsSchema = closedObject({
|
||||
originatingTo: Type.Optional(Type.String()),
|
||||
originatingAccountId: Type.Optional(Type.String()),
|
||||
originatingThreadId: Type.Optional(Type.String()),
|
||||
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
||||
attachments: Type.Optional(ChatAttachmentsSchema),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
systemInputProvenance: Type.Optional(InputProvenanceSchema),
|
||||
systemProvenanceReceipt: Type.Optional(Type.String()),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { ChatAttachmentsSchema } from "./logs-chat.js";
|
||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
|
||||
/** Creates or adopts a session with optional model, label, and parent linkage. */
|
||||
export const SessionsCreateParamsSchema = closedObject({
|
||||
key: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
model: Type.Optional(NonEmptyString),
|
||||
catalogId: Type.Optional(NonEmptyString),
|
||||
parentSessionKey: Type.Optional(NonEmptyString),
|
||||
fork: Type.Optional(
|
||||
Type.Boolean({ description: "Fork the parent transcript; requires parentSessionKey." }),
|
||||
),
|
||||
emitCommandHooks: Type.Optional(Type.Boolean()),
|
||||
task: Type.Optional(Type.String()),
|
||||
message: Type.Optional(Type.String()),
|
||||
attachments: Type.Optional(ChatAttachmentsSchema),
|
||||
worktree: Type.Optional(Type.Boolean()),
|
||||
worktreeBaseRef: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description: "Base ref for the new managed worktree branch. Requires worktree=true.",
|
||||
}),
|
||||
),
|
||||
worktreeName: Type.Optional(
|
||||
Type.String({
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||
description: "Managed worktree name; becomes branch openclaw/<name>. Requires worktree=true.",
|
||||
}),
|
||||
),
|
||||
execNode: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Bind session exec to host=node with this node id/name. Requires operator.admin.",
|
||||
}),
|
||||
),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Absolute source directory for a managed worktree, or the working directory on execNode. Requires operator.admin.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import { closedObject } from "./closed-object.js";
|
||||
import { ErrorShapeSchema } from "./frames.js";
|
||||
import { PluginJsonValueSchema } from "./plugins.js";
|
||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
import { SessionsCreateParamsSchema } from "./sessions-create.js";
|
||||
|
||||
export { SessionsCreateParamsSchema };
|
||||
|
||||
/**
|
||||
* Session protocol schemas.
|
||||
@@ -292,49 +295,6 @@ export const SessionsResolveParamsSchema = closedObject({
|
||||
allowMissing: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
/** Creates or adopts a session with optional model, label, and parent linkage. */
|
||||
export const SessionsCreateParamsSchema = closedObject({
|
||||
key: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
model: Type.Optional(NonEmptyString),
|
||||
catalogId: Type.Optional(NonEmptyString),
|
||||
parentSessionKey: Type.Optional(NonEmptyString),
|
||||
fork: Type.Optional(
|
||||
Type.Boolean({ description: "Fork the parent transcript; requires parentSessionKey." }),
|
||||
),
|
||||
emitCommandHooks: Type.Optional(Type.Boolean()),
|
||||
task: Type.Optional(Type.String()),
|
||||
message: Type.Optional(Type.String()),
|
||||
worktree: Type.Optional(Type.Boolean()),
|
||||
worktreeBaseRef: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description: "Base ref for the new managed worktree branch. Requires worktree=true.",
|
||||
}),
|
||||
),
|
||||
worktreeName: Type.Optional(
|
||||
Type.String({
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||
description: "Managed worktree name; becomes branch openclaw/<name>. Requires worktree=true.",
|
||||
}),
|
||||
),
|
||||
execNode: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Bind session exec to host=node with this node id/name. Requires operator.admin.",
|
||||
}),
|
||||
),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Absolute source directory for a managed worktree, or the working directory on execNode. Requires operator.admin.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const SessionWorktreeInfoSchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
path: NonEmptyString,
|
||||
|
||||
@@ -344,6 +344,7 @@ export type SessionCreateParams = {
|
||||
parentSessionKey?: string;
|
||||
task?: string;
|
||||
message?: string;
|
||||
attachments?: unknown[];
|
||||
};
|
||||
|
||||
/** Parameters for sending a message to an existing session. */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ChatAttachment } from "../chat-attachments.js";
|
||||
|
||||
/** RPC attachment payload shape accepted by chat-like gateway methods. */
|
||||
type RpcAttachmentInput = {
|
||||
export type RpcAttachmentInput = {
|
||||
type?: unknown;
|
||||
mimeType?: unknown;
|
||||
fileName?: unknown;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
normalizeRpcAttachmentsToChatAttachments,
|
||||
type RpcAttachmentInput,
|
||||
} from "./attachment-normalize.js";
|
||||
|
||||
function resolveOptionalInitialSessionMessage(params: {
|
||||
task?: unknown;
|
||||
message?: unknown;
|
||||
}): string | undefined {
|
||||
if (typeof params.task === "string" && params.task.trim()) {
|
||||
return params.task;
|
||||
}
|
||||
if (typeof params.message === "string" && params.message.trim()) {
|
||||
return params.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSessionCreateInitialTurn(params: {
|
||||
attachments?: unknown[];
|
||||
message?: unknown;
|
||||
task?: unknown;
|
||||
}) {
|
||||
const message = resolveOptionalInitialSessionMessage(params);
|
||||
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(
|
||||
params.attachments as RpcAttachmentInput[] | undefined,
|
||||
);
|
||||
if (params.attachments?.length && !message && normalizedAttachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const attachments = normalizedAttachments.length ? normalizedAttachments : undefined;
|
||||
return {
|
||||
attachments,
|
||||
hasInitialTurn: message !== undefined || attachments !== undefined,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAttachPendingMessageSeq(params: {
|
||||
cached?: boolean;
|
||||
payload: unknown;
|
||||
}): boolean {
|
||||
if (params.cached) {
|
||||
return false;
|
||||
}
|
||||
const status =
|
||||
params.payload && typeof params.payload === "object"
|
||||
? (params.payload as { status?: unknown }).status
|
||||
: undefined;
|
||||
return status === "started";
|
||||
}
|
||||
@@ -172,6 +172,10 @@ import {
|
||||
} from "./session-active-runs.js";
|
||||
import { resolveSessionCatalogCreateTarget } from "./session-catalog.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import {
|
||||
resolveSessionCreateInitialTurn,
|
||||
shouldAttachPendingMessageSeq,
|
||||
} from "./session-create-initial-turn.js";
|
||||
import type {
|
||||
GatewayClient,
|
||||
GatewayRequestContext,
|
||||
@@ -407,30 +411,6 @@ function loadSessionEntriesForTarget(params: {
|
||||
return { target, storePath: target.storePath, store, entry };
|
||||
}
|
||||
|
||||
function resolveOptionalInitialSessionMessage(params: {
|
||||
task?: unknown;
|
||||
message?: unknown;
|
||||
}): string | undefined {
|
||||
if (typeof params.task === "string" && params.task.trim()) {
|
||||
return params.task;
|
||||
}
|
||||
if (typeof params.message === "string" && params.message.trim()) {
|
||||
return params.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldAttachPendingMessageSeq(params: { payload: unknown; cached?: boolean }): boolean {
|
||||
if (params.cached) {
|
||||
return false;
|
||||
}
|
||||
const status =
|
||||
params.payload && typeof params.payload === "object"
|
||||
? (params.payload as { status?: unknown }).status
|
||||
: undefined;
|
||||
return status === "started";
|
||||
}
|
||||
|
||||
function emitSessionOperation(
|
||||
context: Pick<GatewayRequestContext, "broadcastToConnIds" | "getSessionEventSubscriberConnIds">,
|
||||
payload: Omit<SessionOperationEvent, "ts">,
|
||||
@@ -1557,7 +1537,23 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const initialMessage = resolveOptionalInitialSessionMessage(p);
|
||||
const initialTurn = resolveSessionCreateInitialTurn(p);
|
||||
if (!initialTurn) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"sessions.create attachments require usable content",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
attachments: initialAttachments,
|
||||
hasInitialTurn,
|
||||
message: initialMessage,
|
||||
} = initialTurn;
|
||||
const requestedCwd = normalizeOptionalString(p.cwd);
|
||||
const requestedExecNode = normalizeOptionalString(p.execNode);
|
||||
if (requestedCwd && p.worktree !== true && !requestedExecNode) {
|
||||
@@ -1634,7 +1630,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
!targetKey &&
|
||||
parentSessionKey &&
|
||||
p.emitCommandHooks === true &&
|
||||
!initialMessage &&
|
||||
!hasInitialTurn &&
|
||||
cfg.session?.dmScope === "main"
|
||||
) {
|
||||
const parent = loadSessionEntry(
|
||||
@@ -1769,10 +1765,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
clearSpawnedCwd: p.worktree !== true,
|
||||
fork: p.fork,
|
||||
emitCommandHooks: p.emitCommandHooks,
|
||||
resetMainWhenUnspecified: !initialMessage,
|
||||
resetMainWhenUnspecified: !hasInitialTurn,
|
||||
commandSource: "webchat",
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
afterCreate: initialMessage
|
||||
afterCreate: hasInitialTurn
|
||||
? async ({ key, agentId, entry, storePath }) => {
|
||||
messageSeq =
|
||||
(await readSessionMessageCountAsync({
|
||||
@@ -1790,8 +1786,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
params: {
|
||||
sessionKey: key,
|
||||
...(key === "global" ? { agentId } : {}),
|
||||
message: initialMessage,
|
||||
message: initialMessage ?? "",
|
||||
idempotencyKey: randomUUID(),
|
||||
...(initialAttachments ? { attachments: initialAttachments } : {}),
|
||||
},
|
||||
respond: (ok, payload, error, meta) => {
|
||||
if (ok && payload && typeof payload === "object") {
|
||||
|
||||
@@ -1813,6 +1813,53 @@ test("sessions.create can start the first agent turn from an initial task", asyn
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.create forwards an attachment-only first turn", async () => {
|
||||
await createSessionStoreDir();
|
||||
testState.agentsConfig = { list: [{ id: "main", default: true }] };
|
||||
const { chatHandlers } = await import("./server-methods/chat.js");
|
||||
const chatSend = vi.spyOn(chatHandlers, "chat.send").mockImplementation(async ({ respond }) => {
|
||||
respond(true, { runId: "attachment-run", status: "started" });
|
||||
});
|
||||
const attachment = {
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
fileName: "pixel.png",
|
||||
content:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await directSessionReq<{ runStarted?: boolean; runId?: string }>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", message: "", attachments: [attachment] },
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
expect(created.payload).toMatchObject({ runStarted: true, runId: "attachment-run" });
|
||||
expect(chatSend.mock.calls[0]?.[0].params).toMatchObject({
|
||||
message: "",
|
||||
attachments: [attachment],
|
||||
});
|
||||
} finally {
|
||||
chatSend.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create rejects unusable attachment-only input before creating a session", async () => {
|
||||
await createSessionStoreDir();
|
||||
testState.agentsConfig = { list: [{ id: "main", default: true }] };
|
||||
|
||||
const created = await directSessionReq("sessions.create", {
|
||||
agentId: "main",
|
||||
attachments: [null],
|
||||
});
|
||||
|
||||
expect(created.ok).toBe(false);
|
||||
expect(created.error?.message).toContain("attachments require usable content");
|
||||
const listed = await directSessionReq<{ sessions?: unknown[] }>("sessions.list", {});
|
||||
expect(listed.payload?.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
test("sessions.create rejects replacing its parent key", async () => {
|
||||
await createSessionStoreDir();
|
||||
testState.agentsConfig = { list: [{ id: "main", default: true }] };
|
||||
|
||||
@@ -427,6 +427,102 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles authoritative history before a trailing final by run identity", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, { historyMessages: [] });
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.fill("reconcile the terminal event ordering");
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
const send = await gateway.waitForRequest("chat.send");
|
||||
const runId = requireString(
|
||||
requireRecord(send.params).idempotencyKey,
|
||||
"chat send idempotency key",
|
||||
);
|
||||
const finalText = "One authoritative final response.";
|
||||
const messageId = "assistant-authoritative-final";
|
||||
const authoritative = {
|
||||
__openclaw: { id: messageId, seq: 2 },
|
||||
content: [{ text: finalText, type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await gateway.emitGatewayEvent("chat", {
|
||||
deltaText: finalText,
|
||||
message: {
|
||||
content: [{ text: finalText, type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
runId,
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
});
|
||||
await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor();
|
||||
await gateway.setHistoryMessages([
|
||||
{
|
||||
__openclaw: { id: "user-reconcile", seq: 1 },
|
||||
content: [{ text: "reconcile the terminal event ordering", type: "text" }],
|
||||
role: "user",
|
||||
timestamp: Date.now() - 1,
|
||||
},
|
||||
authoritative,
|
||||
]);
|
||||
const historyRequestsBefore = (await gateway.getRequests("chat.history")).length;
|
||||
await gateway.emitGatewayEvent("session.message", {
|
||||
activeRunIds: [],
|
||||
clientRunId: runId,
|
||||
hasActiveRun: false,
|
||||
message: authoritative,
|
||||
messageId,
|
||||
messageSeq: 2,
|
||||
session: {
|
||||
activeRunIds: [],
|
||||
hasActiveRun: false,
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
status: "done",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
sessionKey: "main",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("chat.history")).length)
|
||||
.toBeGreaterThan(historyRequestsBefore);
|
||||
await page.getByText(finalText, { exact: true }).waitFor();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
await gateway.emitChatFinal({ runId, text: finalText });
|
||||
await expect
|
||||
.poll(() => page.locator(".chat-group.assistant .chat-duplicate-count").count())
|
||||
.toBe(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
await gateway.emitChatFinal({ runId: "a-different-legitimate-run", text: finalText });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(),
|
||||
)
|
||||
.toBe(2);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the selected session transcript after a hard reload", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
|
||||
@@ -27,6 +27,26 @@ const NODE_PICKED = "/Users/peter/Projects";
|
||||
const NODE_UNC = "\\\\server\\share\\repo";
|
||||
const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo";
|
||||
|
||||
const ONE_PIXEL_PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
|
||||
async function pastePng(target: Locator, count = 1) {
|
||||
await target.evaluate(
|
||||
(element, { base64, fileCount }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
const clipboard = new DataTransfer();
|
||||
for (let index = 0; index < fileCount; index += 1) {
|
||||
const fileName = fileCount === 1 ? "pixel.png" : `pixel-${index + 1}.png`;
|
||||
clipboard.items.add(new File([bytes], fileName, { type: "image/png" }));
|
||||
}
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }),
|
||||
);
|
||||
},
|
||||
{ base64: ONE_PIXEL_PNG_B64, fileCount: count },
|
||||
);
|
||||
}
|
||||
|
||||
function installRepositorySwitchGateway(page: Page, sessionKey: string) {
|
||||
return installMockGateway(page, {
|
||||
workspaceGit: true,
|
||||
@@ -143,6 +163,285 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("pastes an image into the draft and forwards it with the initial turn", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:image-draft", runStarted: true },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const message = page.locator(".new-session-page__message");
|
||||
await message.waitFor();
|
||||
await pastePng(message);
|
||||
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
agentId: "main",
|
||||
message: "",
|
||||
attachments: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
fileName: "pixel.png",
|
||||
content: ONE_PIXEL_PNG_B64,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for pasted image reads before enabling session creation", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL")
|
||||
?.value as FileReader["readAsDataURL"];
|
||||
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
||||
(globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead =
|
||||
() => readAsDataUrl.call(this, blob);
|
||||
};
|
||||
});
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
const submit = page.getByRole("button", { name: "Start session" });
|
||||
await composer.fill("include the image that is still loading");
|
||||
await pastePng(composer);
|
||||
|
||||
await expect.poll(() => submit.isDisabled()).toBe(true);
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
await page.evaluate(() => {
|
||||
const finish = (globalThis as unknown as { finishPastedImageRead?: () => void })
|
||||
.finishPastedImageRead;
|
||||
if (!finish) {
|
||||
throw new Error("Pasted image read was not started");
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await expect.poll(() => submit.isEnabled()).toBe(true);
|
||||
await submit.click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
message: "include the image that is still loading",
|
||||
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases a completed file when the rest of its pasted batch is aborted", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL")
|
||||
?.value as FileReader["readAsDataURL"];
|
||||
let readCount = 0;
|
||||
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
readAsDataUrl.call(this, blob);
|
||||
}
|
||||
};
|
||||
const createObjectURL = URL.createObjectURL.bind(URL);
|
||||
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
|
||||
const proof = { created: 0, revoked: 0 };
|
||||
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
|
||||
URL.createObjectURL = (blob: Blob) => {
|
||||
proof.created += 1;
|
||||
return createObjectURL(blob);
|
||||
};
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
proof.revoked += 1;
|
||||
revokeObjectURL(url);
|
||||
};
|
||||
});
|
||||
await installMockGateway(page);
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
await pastePng(composer, 2);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { attachmentUrlProof: { created: number } })
|
||||
.attachmentUrlProof.created,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: { context: { navigate: (routeId: string) => void } };
|
||||
};
|
||||
app.runtime?.context.navigate("chat");
|
||||
});
|
||||
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { attachmentUrlProof: { revoked: number } })
|
||||
.attachmentUrlProof.revoked,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases pasted image previews after remove, reset, disconnect, and success", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
const createObjectURL = URL.createObjectURL.bind(URL);
|
||||
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
|
||||
const proof = { created: 0, revoked: 0 };
|
||||
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
|
||||
URL.createObjectURL = (blob: Blob) => {
|
||||
proof.created += 1;
|
||||
return createObjectURL(blob);
|
||||
};
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
proof.revoked += 1;
|
||||
revokeObjectURL(url);
|
||||
};
|
||||
});
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:preview-cleanup", runStarted: true },
|
||||
},
|
||||
});
|
||||
const proof = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } })
|
||||
.attachmentUrlProof,
|
||||
);
|
||||
const navigate = (routeId: string, search = "") =>
|
||||
page.evaluate(
|
||||
({ targetRouteId, targetSearch }) => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: {
|
||||
context: {
|
||||
navigate: (routeId: string, options?: { search?: string }) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (!app.runtime) {
|
||||
throw new Error("OpenClaw application runtime is unavailable");
|
||||
}
|
||||
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
|
||||
},
|
||||
{ targetRouteId: routeId, targetSearch: search },
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("button", { name: "Remove attachment" }).click();
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(1);
|
||||
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await navigate("new-session", "?agent=main&catalog=missing");
|
||||
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0);
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(2);
|
||||
|
||||
await navigate("new-session");
|
||||
await composer.waitFor();
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await navigate("chat");
|
||||
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
|
||||
await expect.poll(async () => (await proof()).revoked).toBe(3);
|
||||
|
||||
await navigate("new-session");
|
||||
await composer.waitFor();
|
||||
await pastePng(composer);
|
||||
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
await page.waitForURL(
|
||||
(url) => url.searchParams.get("session") === "agent:main:preview-cleanup",
|
||||
);
|
||||
await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("selects the model for a plain new session", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
models: [
|
||||
{ id: "gpt-5.5", name: "GPT 5.5", provider: "openai" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
|
||||
],
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:model-draft", runStarted: true },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const modelSelect = page.locator('[data-chat-model-select="true"]');
|
||||
await modelSelect.waitFor();
|
||||
await modelSelect.click();
|
||||
await page.locator('[data-chat-model-provider="anthropic"]').click();
|
||||
await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click();
|
||||
await page.locator(".new-session-page__message").fill("use this model");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
message: "use this model",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("drafts a session with a browsed folder and creates it on first message", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
@@ -1336,10 +1635,15 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
await page.locator(".new-session-page__message").fill(message);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
await composer.fill(message);
|
||||
await pastePng(composer);
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({ message });
|
||||
expect(create.params).toMatchObject({
|
||||
message,
|
||||
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
|
||||
});
|
||||
|
||||
await page.waitForURL((url) => url.searchParams.get("session") === sessionKey, {
|
||||
timeout: 30_000,
|
||||
@@ -1357,13 +1661,80 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Retry queued message" }).click();
|
||||
const retry = await gateway.waitForRequest("chat.send");
|
||||
expect(retry.params).toMatchObject({ sessionKey, message });
|
||||
expect(retry.params).toMatchObject({
|
||||
sessionKey,
|
||||
message,
|
||||
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
|
||||
});
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("adopts a created session when rejected-turn persistence exceeds browser storage", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem")
|
||||
?.value as Storage["setItem"];
|
||||
Storage.prototype.setItem = function (key: string, value: string) {
|
||||
if (key.startsWith("openclaw.control.chatComposer.v2:")) {
|
||||
throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
}
|
||||
return setItem.call(this, key, value);
|
||||
};
|
||||
});
|
||||
const sessionKey = "agent:main:storage-failed-initial-turn";
|
||||
const message = "retry this in the session that already exists";
|
||||
const runError = "initial send rejected";
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": {
|
||||
key: sessionKey,
|
||||
runStarted: false,
|
||||
runError: { code: "INVALID_REQUEST", message: runError },
|
||||
},
|
||||
"chat.history": {
|
||||
messages: [],
|
||||
sessionId: "storage-failed-initial-turn",
|
||||
sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" },
|
||||
},
|
||||
"chat.send": { runId: "storage-failure-retry", status: "started" },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}new`);
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
await composer.fill(message);
|
||||
await pastePng(composer);
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
|
||||
await page.waitForURL((url) => url.searchParams.get("session") === sessionKey, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect.poll(() => page.locator(".chat-queue__text").allInnerTexts()).toContain(message);
|
||||
await expect
|
||||
.poll(() => page.locator(".chat-queue__error").allInnerTexts())
|
||||
.toContain(runError);
|
||||
await page.getByRole("button", { name: "Retry queued message" }).click();
|
||||
const retry = await gateway.waitForRequest("chat.send");
|
||||
expect(retry.params).toMatchObject({
|
||||
sessionKey,
|
||||
message,
|
||||
attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }],
|
||||
});
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("browses capable nodes and accepts manual paths for exec-only nodes", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
|
||||
Generated
+25
-2
@@ -1,5 +1,28 @@
|
||||
{
|
||||
"fallbacks": {},
|
||||
"sourceHash": "0c13460dca0cb1cfcf4d17d97316218cb003189bf09681a53dd54cedebb4225b",
|
||||
"fallbacks": {
|
||||
"newSession.readingAttachment": [
|
||||
"ar",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"id",
|
||||
"it",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW"
|
||||
]
|
||||
},
|
||||
"sourceHash": "b61326c5179cddcc954999eae56871241d84cc9626f7013688e820db779258a5",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
@@ -487,6 +487,7 @@ export const en: TranslationMap = {
|
||||
worktreeNamePlaceholder: "auto",
|
||||
worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.",
|
||||
messagePlaceholder: "What should this session work on?",
|
||||
readingAttachment: "Reading attachment",
|
||||
start: "Start session",
|
||||
starting: "Starting…",
|
||||
createFailed: "Couldn't create the session.",
|
||||
|
||||
@@ -24,6 +24,8 @@ export type SessionCreateParams = {
|
||||
cwd?: string;
|
||||
/** First message; the gateway creates the session and starts the run in one call. */
|
||||
message?: string;
|
||||
/** Attachments for the first message, using the chat.send wire format. */
|
||||
attachments?: unknown[];
|
||||
task?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { getChatAttachmentDataUrl } from "./attachment-payload-store.ts";
|
||||
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = match[1];
|
||||
const content = match[2];
|
||||
return mimeType && content ? { mimeType, content } : null;
|
||||
}
|
||||
|
||||
/** Converts composer attachments into the base64 payload accepted by chat.send. */
|
||||
export function buildChatApiAttachments(attachments?: readonly ChatAttachment[]) {
|
||||
return attachments?.length
|
||||
? attachments
|
||||
.map((attachment) => {
|
||||
const dataUrl = getChatAttachmentDataUrl(attachment);
|
||||
const parsed = dataUrl ? dataUrlToBase64(dataUrl) : null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: parsed.mimeType.startsWith("image/") ? "image" : "file",
|
||||
mimeType: parsed.mimeType,
|
||||
fileName: attachment.fileName,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((attachment): attachment is NonNullable<typeof attachment> => attachment !== null)
|
||||
: undefined;
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
clearToolStreamSegments,
|
||||
hasVisibleStreamParts,
|
||||
} from "./stream-reconciliation.ts";
|
||||
import {
|
||||
authoritativeHistoryAppliedForRun,
|
||||
rememberLiveTerminalRun,
|
||||
} from "./terminal-message-identity.ts";
|
||||
|
||||
export type { ChatEventPayload } from "./chat-history.ts";
|
||||
|
||||
@@ -162,6 +166,11 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
state.chatRunId !== null &&
|
||||
typeof payload.runId === "string" &&
|
||||
payload.runId === state.chatRunId;
|
||||
const authoritativeTerminalMatches = Boolean(
|
||||
payload.runId &&
|
||||
authoritativeHistoryAppliedForRun(state, payload.runId) &&
|
||||
chatEventSessionMatches(state, payload),
|
||||
);
|
||||
if (!sessionMatches && !activeRunMatches) {
|
||||
if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
@@ -221,7 +230,11 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) {
|
||||
if (authoritativeTerminalMatches) {
|
||||
// History already owns this run's terminal message. Discard the live
|
||||
// projection; reconcileTerminalRun below clears its remaining stream.
|
||||
clearToolStreamSegments(state);
|
||||
} else if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) {
|
||||
if (
|
||||
hasVisibleStreamParts(state, {
|
||||
includeCurrent: false,
|
||||
@@ -233,7 +246,10 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
});
|
||||
clearToolStreamSegments(state);
|
||||
}
|
||||
state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, finalMessage);
|
||||
state.chatMessages = appendTerminalAssistantMessage(
|
||||
state.chatMessages,
|
||||
rememberLiveTerminalRun(finalMessage, terminalRunId),
|
||||
);
|
||||
} else {
|
||||
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
|
||||
const DEFAULT_RETRY_MS = 500;
|
||||
const MAX_RETRY_MS = 5_000;
|
||||
|
||||
export function isRetryableStartupUnavailable(
|
||||
err: unknown,
|
||||
method: string,
|
||||
): err is GatewayRequestError {
|
||||
if (!(err instanceof GatewayRequestError)) {
|
||||
return false;
|
||||
}
|
||||
if (err.gatewayCode !== "UNAVAILABLE" || !err.retryable) {
|
||||
return false;
|
||||
}
|
||||
const details = err.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return true;
|
||||
}
|
||||
const detailMethod = (details as { method?: unknown }).method;
|
||||
return typeof detailMethod !== "string" || detailMethod === method;
|
||||
}
|
||||
|
||||
export function isUnknownGatewayMethodError(
|
||||
err: unknown,
|
||||
method: string,
|
||||
): err is GatewayRequestError {
|
||||
return (
|
||||
err instanceof GatewayRequestError &&
|
||||
err.gatewayCode === "INVALID_REQUEST" &&
|
||||
err.message.includes(`unknown method: ${method}`)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveStartupRetryDelayMs(err: GatewayRequestError): number {
|
||||
const retryAfterMs = typeof err.retryAfterMs === "number" ? err.retryAfterMs : DEFAULT_RETRY_MS;
|
||||
return Math.min(Math.max(retryAfterMs, 100), MAX_RETRY_MS);
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
// Control UI page module owns Chat transcript loading and selected-session message subscription.
|
||||
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
GatewayRequestError,
|
||||
type GatewayBrowserClient,
|
||||
type GatewayHelloOk,
|
||||
} from "../../api/gateway.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
GatewaySessionRow,
|
||||
@@ -47,6 +43,12 @@ import {
|
||||
resolveUiSelectedSessionAgentId,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
|
||||
import {
|
||||
isRetryableStartupUnavailable,
|
||||
isUnknownGatewayMethodError,
|
||||
resolveStartupRetryDelayMs,
|
||||
sleep,
|
||||
} from "./chat-history-retry.ts";
|
||||
import {
|
||||
isLocallyOptimisticHistoryMessage,
|
||||
messageDisplaySignature,
|
||||
@@ -76,14 +78,13 @@ import {
|
||||
prunePersistedToolStreamMessages,
|
||||
visibleCurrentAssistantStreamTail,
|
||||
} from "./stream-reconciliation.ts";
|
||||
import { reconcileAuthoritativeTerminalHistory } from "./terminal-message-identity.ts";
|
||||
|
||||
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
||||
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
|
||||
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
|
||||
const CHAT_HISTORY_REQUEST_LIMIT = 100;
|
||||
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
|
||||
const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500;
|
||||
const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
|
||||
const chatHistoryRequestVersions = new WeakMap<object, number>();
|
||||
const selectedSessionMessageSubscriptionGenerations = new WeakMap<object, number>();
|
||||
|
||||
@@ -306,41 +307,6 @@ function collectLateOptimisticTailMessages(
|
||||
return lateTail;
|
||||
}
|
||||
|
||||
function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {
|
||||
if (!(err instanceof GatewayRequestError)) {
|
||||
return false;
|
||||
}
|
||||
if (err.gatewayCode !== "UNAVAILABLE" || !err.retryable) {
|
||||
return false;
|
||||
}
|
||||
const details = err.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return true;
|
||||
}
|
||||
const detailMethod = (details as { method?: unknown }).method;
|
||||
return typeof detailMethod !== "string" || detailMethod === method;
|
||||
}
|
||||
|
||||
function isUnknownGatewayMethodError(err: unknown, method: string): err is GatewayRequestError {
|
||||
return (
|
||||
err instanceof GatewayRequestError &&
|
||||
err.gatewayCode === "INVALID_REQUEST" &&
|
||||
err.message.includes(`unknown method: ${method}`)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStartupRetryDelayMs(err: GatewayRequestError): number {
|
||||
const retryAfterMs =
|
||||
typeof err.retryAfterMs === "number" ? err.retryAfterMs : STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS;
|
||||
return Math.min(Math.max(retryAfterMs, 100), STARTUP_CHAT_HISTORY_MAX_RETRY_MS);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
@@ -1008,14 +974,21 @@ async function loadChatHistoryUncached(
|
||||
state.chatHistoryPagination = resolveChatHistoryPagination(res);
|
||||
applyChatAgentsList(state, res.agentsList, client);
|
||||
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
|
||||
const lateOptimisticTail = collectLateOptimisticTailMessages(
|
||||
const reconciledTerminal = reconcileAuthoritativeTerminalHistory({
|
||||
currentMessages: state.chatMessages,
|
||||
host: state,
|
||||
previousMessages,
|
||||
state.chatMessages,
|
||||
sessionKey,
|
||||
visibleMessages,
|
||||
});
|
||||
const lateOptimisticTail = collectLateOptimisticTailMessages(
|
||||
reconciledTerminal.previousMessages,
|
||||
reconciledTerminal.currentMessages,
|
||||
visibleMessages,
|
||||
);
|
||||
state.chatMessages = preserveOptimisticTailMessages(
|
||||
visibleMessages,
|
||||
previousMessages,
|
||||
reconciledTerminal.previousMessages,
|
||||
shouldHideHistoryMessage,
|
||||
);
|
||||
if (lateOptimisticTail.length > 0) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts";
|
||||
|
||||
export function resolveAssistantAttachmentAuthToken(state: {
|
||||
hello?: { auth?: { deviceToken?: string | null } | null } | null;
|
||||
password?: string | null;
|
||||
settings?: { token?: string | null } | null;
|
||||
}) {
|
||||
return resolveControlUiAuthToken(state);
|
||||
}
|
||||
|
||||
export function dismissChatError(state: {
|
||||
chatError?: string | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode?: string | null;
|
||||
}) {
|
||||
state.lastError = null;
|
||||
state.lastErrorCode = null;
|
||||
state.chatError = null;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
syncSelectedSessionMessageSubscription,
|
||||
type ChatHistoryPagination,
|
||||
} from "./chat-history.ts";
|
||||
import { dismissChatError, resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts";
|
||||
import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts";
|
||||
import { dismissRealtimeTalkError } from "./chat-realtime.ts";
|
||||
import { flushChatQueueForEvent, retryReconnectableQueuedChatSends } from "./chat-send.ts";
|
||||
@@ -85,7 +86,6 @@ import {
|
||||
canCreateChatSession,
|
||||
ChatStateController,
|
||||
createPageState,
|
||||
dismissChatError,
|
||||
handlePageGatewayEvent,
|
||||
refreshChatCommands,
|
||||
refreshChatMetadata,
|
||||
@@ -94,7 +94,6 @@ import {
|
||||
refreshRouteSessionOptions,
|
||||
resetChatStateForRouteSession,
|
||||
retryChatComposerMemoryFallback,
|
||||
resolveAssistantAttachmentAuthToken,
|
||||
resolveChatAgentId,
|
||||
resolveChatAvatarUrl,
|
||||
saveRouteSessionSettings,
|
||||
@@ -102,12 +101,12 @@ import {
|
||||
} from "./chat-state.ts";
|
||||
import { renderChat, resetChatViewState, type ChatProps } from "./chat-view.ts";
|
||||
import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts";
|
||||
import { chatAttachmentFromDataUrl } from "./components/chat-attachments.ts";
|
||||
import {
|
||||
createBackgroundTasksProps,
|
||||
renderBackgroundTasksToggle,
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import { chatAttachmentFromDataUrl } from "./components/chat-composer.ts";
|
||||
import { renderChatControls } from "./components/chat-controls.ts";
|
||||
import {
|
||||
chatPullRequestId,
|
||||
|
||||
@@ -35,7 +35,7 @@ type ChatQueueSessionHost = ChatQueueStoreHost &
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
type ChatQueueScopedSessionHost = ChatQueueSessionHost & SessionScopeHost;
|
||||
export type ChatQueueScopedSessionHost = ChatQueueSessionHost & SessionScopeHost;
|
||||
|
||||
const chatOutboxProjectionHosts = new Set<ChatQueueScopedSessionHost>();
|
||||
// Durable rows use crash-safe states. Overlay live work process-wide so panes
|
||||
@@ -48,7 +48,7 @@ const localRecoveryItemIds = new WeakMap<ChatQueueScopedSessionHost, Set<string>
|
||||
// quota fallback may bypass durable admission on an explicit retry.
|
||||
const volatileQueueItemIds = new WeakMap<ChatQueueScopedSessionHost, Set<string>>();
|
||||
|
||||
function markLocalRecoveryItem(host: ChatQueueScopedSessionHost, id: string): void {
|
||||
export function markLocalRecoveryItem(host: ChatQueueScopedSessionHost, id: string): void {
|
||||
const ids = localRecoveryItemIds.get(host) ?? new Set<string>();
|
||||
ids.add(id);
|
||||
localRecoveryItemIds.set(host, ids);
|
||||
@@ -66,7 +66,7 @@ export function isVolatileQueuedMessage(host: ChatQueueScopedSessionHost, id: st
|
||||
return volatileQueueItemIds.get(host)?.has(id) === true;
|
||||
}
|
||||
|
||||
function markVolatileQueuedMessage(host: ChatQueueScopedSessionHost, id: string): void {
|
||||
export function markVolatileQueuedMessage(host: ChatQueueScopedSessionHost, id: string): void {
|
||||
const ids = volatileQueueItemIds.get(host) ?? new Set<string>();
|
||||
ids.add(id);
|
||||
volatileQueueItemIds.set(host, ids);
|
||||
@@ -384,7 +384,7 @@ export function replacePendingQueuedMessageProjection(
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeChatQueueForScope(
|
||||
export function writeChatQueueForScope(
|
||||
host: ChatQueueScopedSessionHost,
|
||||
sessionKey: string,
|
||||
queue: ChatQueueItem[],
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import { buildChatApiAttachments } from "./attachment-api.ts";
|
||||
import {
|
||||
discardChatAttachmentDataUrls,
|
||||
getChatAttachmentDataUrl,
|
||||
@@ -201,37 +202,6 @@ type ChatSendOptions = {
|
||||
onSideQuestionSendRejected?: () => void;
|
||||
};
|
||||
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = match[1];
|
||||
const content = match[2];
|
||||
return mimeType && content ? { mimeType, content } : null;
|
||||
}
|
||||
|
||||
function buildApiAttachments(attachments?: ChatAttachment[]) {
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
return hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const dataUrl = getChatAttachmentDataUrl(att);
|
||||
const parsed = dataUrl ? dataUrlToBase64(dataUrl) : null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: parsed.mimeType.startsWith("image/") ? "image" : "file",
|
||||
mimeType: parsed.mimeType,
|
||||
fileName: att.fileName,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeAckTimingValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
@@ -295,7 +265,7 @@ async function requestChatSend(
|
||||
message: params.message,
|
||||
deliver: false,
|
||||
idempotencyKey: params.runId,
|
||||
attachments: buildApiAttachments(params.attachments),
|
||||
attachments: buildChatApiAttachments(params.attachments),
|
||||
});
|
||||
if (controlUiReconnectResume) {
|
||||
state.reconnectResumeSessionId = null;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
loadLocalAssistantIdentity,
|
||||
} from "../../app/assistant-identity.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts";
|
||||
import {
|
||||
loadLocalUserIdentity,
|
||||
loadSettings,
|
||||
@@ -111,6 +110,7 @@ import {
|
||||
storedChatOutboxScopeKey,
|
||||
type StoredChatOutboxScope,
|
||||
} from "./composer-persistence.ts";
|
||||
import { admitInitialTurnHandoff } from "./initial-turn-handoff.ts";
|
||||
import {
|
||||
handleChatDraftChange,
|
||||
handleChatInputHistoryKey,
|
||||
@@ -135,6 +135,10 @@ import {
|
||||
scheduleCommittedChatScroll,
|
||||
} from "./scroll.ts";
|
||||
import { cacheChatMessages, readChatMessagesFromCache } from "./session-message-cache.ts";
|
||||
import {
|
||||
clearAuthoritativeTerminal,
|
||||
rememberAuthoritativeTerminal,
|
||||
} from "./terminal-message-identity.ts";
|
||||
import {
|
||||
handleAgentEvent,
|
||||
handleSessionOperationEvent,
|
||||
@@ -302,16 +306,6 @@ export function canCreateChatSession(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssistantAttachmentAuthToken(state: ChatPageHost) {
|
||||
return resolveControlUiAuthToken(state);
|
||||
}
|
||||
|
||||
export function dismissChatError(state: ChatPageHost) {
|
||||
state.lastError = null;
|
||||
state.lastErrorCode = null;
|
||||
state.chatError = null;
|
||||
}
|
||||
|
||||
function saveChatQueueForSession(state: ChatPageHost, sessionKey: string) {
|
||||
const scope = resolveStoredChatOutboxScope(state, sessionKey);
|
||||
const scopeKey = storedChatOutboxScopeKey(scope);
|
||||
@@ -505,6 +499,7 @@ export function resetChatStateForRouteSession(
|
||||
state.chatAvatarSource = null;
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
clearAuthoritativeTerminal(state);
|
||||
resetChatRealtimeConversation(state);
|
||||
state.chatQueue = restoreChatQueueForSession(state, sessionKey);
|
||||
restoreChatComposerState(state);
|
||||
@@ -512,12 +507,13 @@ export function resetChatStateForRouteSession(
|
||||
// projection without rendering through the old route's persistence owner.
|
||||
// switchPaneSession requests an update only after adopting the new baseline.
|
||||
syncVisibleChatQueueProjection(state, { requestUpdate: false });
|
||||
const initialTurn = admitInitialTurnHandoff(state, sessionKey);
|
||||
const { fallback } = resolveChatComposerMemoryFallback(state, sessionKey);
|
||||
if (fallback) {
|
||||
state.chatMessage = fallback.message;
|
||||
state.chatAttachments = [...fallback.attachments];
|
||||
}
|
||||
const restoredStorageFailure = fallback?.storageFailed === true;
|
||||
const restoredStorageFailure = fallback?.storageFailed === true || initialTurn;
|
||||
if (options.previousDraftRetry || restoredStorageFailure) {
|
||||
state.lastError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR;
|
||||
state.chatError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR;
|
||||
@@ -1050,6 +1046,7 @@ function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) {
|
||||
state.selectedChatSessionArchived = event.archived;
|
||||
}
|
||||
const runIdBeforeApply = state.chatRunId;
|
||||
rememberAuthoritativeTerminal({ event, host: state, matchesChat, payload, runIdBeforeApply });
|
||||
const result = reconcileSessionEvent(state, payload);
|
||||
if (runIdBeforeApply && matchesChat) {
|
||||
const runId = event.clientRunId ?? event.runId ?? runIdBeforeApply;
|
||||
|
||||
@@ -21,13 +21,13 @@ import type { ChatSideResult, ChatSideResultPending } from "../../lib/chat/side-
|
||||
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
|
||||
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
|
||||
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
|
||||
import { handleChatAttachmentDrop } from "./components/chat-attachments.ts";
|
||||
import {
|
||||
renderBackgroundTasksRail,
|
||||
renderBackgroundTasksToggle,
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import {
|
||||
handleChatAttachmentDrop,
|
||||
isChatRunWorking,
|
||||
renderChatComposer,
|
||||
resetChatComposerState,
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
// Shared attachment controls for chat and new-session composers.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { ChatAttachment } from "../../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
getChatAttachmentPreviewUrl,
|
||||
registerChatAttachmentPayload,
|
||||
releaseChatAttachmentPayload,
|
||||
} from "../attachment-payload-store.ts";
|
||||
|
||||
const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,audio/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," +
|
||||
".doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const LARGE_PASTE_TEXT_THRESHOLD = 1000;
|
||||
const LARGE_PASTE_TEXT_MIME_TYPE = "text/plain";
|
||||
const LARGE_PASTE_TEXT_FILE_PREFIX = "pasted-text-";
|
||||
const PASTED_TEXT_PREVIEW_MAX_LENGTH = 20;
|
||||
const largePastedTextAttachments = new WeakSet<ChatAttachment>();
|
||||
const pastedTextPreviews = new WeakMap<ChatAttachment, string>();
|
||||
|
||||
export type ChatAttachmentControlsProps = {
|
||||
attachments?: ChatAttachment[];
|
||||
disabled?: boolean;
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
draft?: string;
|
||||
getDraft?: () => string;
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
onDraftChange?: (next: string) => void;
|
||||
onPendingReadsChange?: (delta: 1 | -1) => void;
|
||||
onRequestUpdate?: () => void;
|
||||
readSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[] {
|
||||
return props.getAttachments?.() ?? props.attachments ?? [];
|
||||
}
|
||||
|
||||
function isSupportedChatAttachmentFile(file: Pick<File, "name" | "type">): boolean {
|
||||
if (file.type.startsWith("video/")) {
|
||||
return false;
|
||||
}
|
||||
return !/\.(?:avi|m4v|mov|mp4|mpeg|mpg|webm)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function clickComposerInput(target: HTMLElement, selector: string) {
|
||||
target.closest("details")?.removeAttribute("open");
|
||||
target
|
||||
.closest(".agent-chat__composer-shell, .new-session-page__composer")
|
||||
?.querySelector<HTMLInputElement>(selector)
|
||||
?.click();
|
||||
}
|
||||
|
||||
function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment {
|
||||
const attachment = {
|
||||
id: generateAttachmentId(),
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
fileName: file.name || undefined,
|
||||
sizeBytes: file.size,
|
||||
};
|
||||
return registerChatAttachmentPayload({ attachment, dataUrl, file });
|
||||
}
|
||||
|
||||
export function isLargePastedTextAttachment(attachment: ChatAttachment): boolean {
|
||||
return largePastedTextAttachments.has(attachment);
|
||||
}
|
||||
|
||||
function encodeTextAsDataUrl(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const chunks: string[] = [];
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||
}
|
||||
return `data:${LARGE_PASTE_TEXT_MIME_TYPE};base64,${btoa(chunks.join(""))}`;
|
||||
}
|
||||
|
||||
function createLargePastedTextAttachment(text: string): ChatAttachment {
|
||||
const file = new File([text], `${LARGE_PASTE_TEXT_FILE_PREFIX}${Date.now()}.txt`, {
|
||||
type: LARGE_PASTE_TEXT_MIME_TYPE,
|
||||
});
|
||||
const attachment = chatAttachmentFromFile(file, encodeTextAsDataUrl(text));
|
||||
largePastedTextAttachments.add(attachment);
|
||||
const preview = compactPastedTextPreview(text);
|
||||
if (preview) {
|
||||
pastedTextPreviews.set(attachment, preview);
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function readTextFromDataUrl(dataUrl: string): string | null {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const metadata = match[1];
|
||||
const payload = match[2];
|
||||
if (metadata === undefined || payload === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (metadata.toLowerCase().includes(";base64")) {
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(payload.replace(/\+/g, "%20"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactPastedTextPreview(text: string): string | null {
|
||||
const normalized = text.replace(/\s+/gu, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length <= PASTED_TEXT_PREVIEW_MAX_LENGTH) {
|
||||
return normalized;
|
||||
}
|
||||
return `${truncateUtf16Safe(normalized, PASTED_TEXT_PREVIEW_MAX_LENGTH).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function pastedTextPreview(attachment: ChatAttachment): string {
|
||||
return pastedTextPreviews.get(attachment) ?? attachment.fileName ?? "Attached file";
|
||||
}
|
||||
|
||||
function appendPastedTextToDraft(draft: string, text: string): string {
|
||||
if (!draft.trim()) {
|
||||
return text;
|
||||
}
|
||||
return `${draft.replace(/\s+$/u, "")}\n\n${text}`;
|
||||
}
|
||||
|
||||
function handleLargeTextPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps): boolean {
|
||||
if (!props.onAttachmentsChange) {
|
||||
return false;
|
||||
}
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
if (!text || text.length <= LARGE_PASTE_TEXT_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
e.preventDefault();
|
||||
const attachment = createLargePastedTextAttachment(text);
|
||||
props.onAttachmentsChange([...currentAttachments(props), attachment]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataImageClipboardFile(
|
||||
dataUrl: string,
|
||||
baseName = "pasted-image",
|
||||
): { file: File; dataUrl: string } | null {
|
||||
const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = match[1]?.toLowerCase();
|
||||
const base64Source = match[2];
|
||||
if (!mimeType || !base64Source) {
|
||||
return null;
|
||||
}
|
||||
if (!isSupportedChatAttachmentFile({ name: baseName, type: mimeType })) {
|
||||
return null;
|
||||
}
|
||||
const base64 = base64Source.replace(/\s+/g, "");
|
||||
try {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";
|
||||
return {
|
||||
file: new File([bytes], `${baseName}.${extension}`, { type: mimeType }),
|
||||
dataUrl: `data:${mimeType};base64,${base64}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a registered chat attachment from a base64 image data URL. */
|
||||
export function chatAttachmentFromDataUrl(
|
||||
dataUrl: string,
|
||||
fileName: string,
|
||||
): ChatAttachment | null {
|
||||
const baseName = fileName.replace(/\.[a-z0-9]+$/i, "") || "image";
|
||||
const parsed = dataImageClipboardFile(dataUrl, baseName);
|
||||
return parsed ? chatAttachmentFromFile(parsed.file, parsed.dataUrl) : null;
|
||||
}
|
||||
|
||||
function readAttachmentFile(
|
||||
file: File,
|
||||
props: ChatAttachmentControlsProps,
|
||||
): Promise<ChatAttachment | null> {
|
||||
if (props.readSignal?.aborted) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
props.onPendingReadsChange?.(1);
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
let settled = false;
|
||||
const finish = (attachment: ChatAttachment | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
props.readSignal?.removeEventListener("abort", abort);
|
||||
props.onPendingReadsChange?.(-1);
|
||||
resolve(attachment);
|
||||
};
|
||||
const abort = () => {
|
||||
reader.abort();
|
||||
finish(null);
|
||||
};
|
||||
props.readSignal?.addEventListener("abort", abort, { once: true });
|
||||
reader.addEventListener("error", () => finish(null), { once: true });
|
||||
reader.addEventListener("abort", () => finish(null), { once: true });
|
||||
reader.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : null;
|
||||
finish(
|
||||
dataUrl && !props.readSignal?.aborted ? chatAttachmentFromFile(file, dataUrl) : null,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function appendAttachmentFiles(files: readonly File[], props: ChatAttachmentControlsProps) {
|
||||
const supported = files.filter(isSupportedChatAttachmentFile);
|
||||
if (!props.onAttachmentsChange || supported.length === 0) {
|
||||
return;
|
||||
}
|
||||
const additions = (
|
||||
await Promise.all(supported.map((file) => readAttachmentFile(file, props)))
|
||||
).filter((attachment): attachment is ChatAttachment => attachment !== null);
|
||||
if (props.readSignal?.aborted) {
|
||||
for (const attachment of additions) {
|
||||
releaseChatAttachmentPayload(attachment.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (additions.length === 0) {
|
||||
return;
|
||||
}
|
||||
props.onAttachmentsChange([...currentAttachments(props), ...additions]);
|
||||
}
|
||||
|
||||
export function handleChatAttachmentPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const imageFiles = Array.from(items)
|
||||
.filter((item) => item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
if (imageFiles.length === 0) {
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
const pasted = text ? dataImageClipboardFile(text) : null;
|
||||
if (!pasted) {
|
||||
handleLargeTextPaste(e, props);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
props.onAttachmentsChange([
|
||||
...currentAttachments(props),
|
||||
chatAttachmentFromFile(pasted.file, pasted.dataUrl),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
void appendAttachmentFiles(imageFiles, props);
|
||||
}
|
||||
|
||||
function showPastedTextInComposer(att: ChatAttachment, props: ChatAttachmentControlsProps): void {
|
||||
const dataUrl = getChatAttachmentDataUrl(att);
|
||||
const text = dataUrl ? readTextFromDataUrl(dataUrl) : null;
|
||||
if (!text || !props.onDraftChange) {
|
||||
return;
|
||||
}
|
||||
const nextAttachments = currentAttachments(props).filter(
|
||||
(attachment) => attachment.id !== att.id,
|
||||
);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(nextAttachments);
|
||||
props.onDraftChange(appendPastedTextToDraft(props.getDraft?.() ?? props.draft ?? "", text));
|
||||
props.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = [...(input.files ?? [])];
|
||||
input.value = "";
|
||||
void appendAttachmentFiles(files, props);
|
||||
}
|
||||
|
||||
export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentControlsProps) {
|
||||
e.preventDefault();
|
||||
void appendAttachmentFiles([...(e.dataTransfer?.files ?? [])], props);
|
||||
}
|
||||
|
||||
export function renderChatAttachmentInputs(props: ChatAttachmentControlsProps) {
|
||||
return html`
|
||||
<input
|
||||
type="file"
|
||||
accept=${CHAT_ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
class="agent-chat__file-input"
|
||||
?disabled=${props.disabled}
|
||||
@change=${(event: Event) => {
|
||||
if (!props.disabled) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="agent-chat__photo-input"
|
||||
?disabled=${props.disabled}
|
||||
@change=${(event: Event) => {
|
||||
if (!props.disabled) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="agent-chat__camera-input"
|
||||
?disabled=${props.disabled}
|
||||
@change=${(event: Event) => {
|
||||
if (!props.disabled) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatAttachmentMenu(props: ChatAttachmentControlsProps) {
|
||||
return html`
|
||||
<wa-dropdown
|
||||
class="agent-chat__attach-menu"
|
||||
placement="top-start"
|
||||
aria-label=${t("chat.composer.addAttachment")}
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const menu = event.currentTarget as HTMLElement;
|
||||
const selector =
|
||||
event.detail.item.value === "camera"
|
||||
? ".agent-chat__camera-input"
|
||||
: event.detail.item.value === "photo"
|
||||
? ".agent-chat__photo-input"
|
||||
: event.detail.item.value === "file"
|
||||
? ".agent-chat__file-input"
|
||||
: null;
|
||||
if (selector) {
|
||||
clickComposerInput(menu, selector);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
class="agent-chat__input-btn agent-chat__input-btn--attach"
|
||||
aria-label=${t("chat.composer.addAttachment")}
|
||||
?disabled=${props.disabled}
|
||||
title=${t("chat.composer.addAttachment")}
|
||||
@pointerdown=${(event: PointerEvent) => {
|
||||
const composer = (event.currentTarget as HTMLElement)
|
||||
.closest(".agent-chat__composer-shell")
|
||||
?.querySelector("textarea");
|
||||
if (document.activeElement === composer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
|
||||
<span slot="icon" aria-hidden="true">${icons.camera}</span>
|
||||
<span>${t("chat.composer.takePhoto")}</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
|
||||
<span slot="icon" aria-hidden="true">${icons.image}</span>
|
||||
<span>${t("chat.composer.attachPhoto")}</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
|
||||
<span slot="icon" aria-hidden="true">${icons.folder}</span>
|
||||
<span>${t("chat.composer.attachFileOption")}</span>
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-attachments-preview">
|
||||
${attachments.map(
|
||||
(att) => html`
|
||||
<div
|
||||
class=${[
|
||||
"chat-attachment-thumb",
|
||||
att.mimeType.startsWith("image/") ? "" : "chat-attachment-thumb--file",
|
||||
isLargePastedTextAttachment(att) ? "chat-attachment-thumb--pasted-text" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
${att.mimeType.startsWith("image/") && getChatAttachmentPreviewUrl(att)
|
||||
? html`<img src=${getChatAttachmentPreviewUrl(att)!} alt="Attachment preview" />`
|
||||
: isLargePastedTextAttachment(att)
|
||||
? html`
|
||||
<div class="chat-attachment-file chat-attachment-file--pasted-text">
|
||||
<span class="chat-attachment-file__icon">${icons.fileText}</span>
|
||||
<span class="chat-attachment-file__body">
|
||||
<span class="chat-attachment-file__name">${pastedTextPreview(att)}</span>
|
||||
<button
|
||||
class="chat-attachment-text-action"
|
||||
type="button"
|
||||
aria-label=${t("worktrees.restore")}
|
||||
?disabled=${props.disabled}
|
||||
@click=${() => showPastedTextInComposer(att, props)}
|
||||
>
|
||||
${t("worktrees.restore")}
|
||||
<span aria-hidden="true">${icons.chevronRight}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<openclaw-tooltip .content=${att.fileName ?? "Attached file"}>
|
||||
<div class="chat-attachment-file">
|
||||
<span class="chat-attachment-file__icon">${icons.paperclip}</span>
|
||||
<span class="chat-attachment-file__name"
|
||||
>${att.fileName ?? "Attached file"}</span
|
||||
>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`}
|
||||
<openclaw-tooltip .content=${t("chat.composer.removeAttachment")}>
|
||||
<button
|
||||
class="chat-attachment-remove"
|
||||
type="button"
|
||||
aria-label=${t("chat.composer.removeAttachment")}
|
||||
?disabled=${props.disabled}
|
||||
@click=${() => {
|
||||
const next = currentAttachments(props).filter((a) => a.id !== att.id);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(next);
|
||||
}}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -38,12 +38,6 @@ import {
|
||||
goalElapsedMs,
|
||||
} from "../../../lib/session-goal.ts";
|
||||
import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
getChatAttachmentPreviewUrl,
|
||||
registerChatAttachmentPayload,
|
||||
releaseChatAttachmentPayload,
|
||||
} from "../attachment-payload-store.ts";
|
||||
import { exportChatMarkdown } from "../export.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
@@ -51,6 +45,13 @@ import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.ts";
|
||||
import type { RealtimeTalkStatus } from "../realtime-talk.ts";
|
||||
import { CHAT_RUN_STATUS_TOAST_DURATION_MS, type ChatRunUiStatus } from "../run-lifecycle.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "../tool-stream.ts";
|
||||
import {
|
||||
handleChatAttachmentPaste,
|
||||
isLargePastedTextAttachment,
|
||||
renderAttachmentPreview,
|
||||
renderChatAttachmentInputs,
|
||||
renderChatAttachmentMenu,
|
||||
} from "./chat-attachments.ts";
|
||||
import {
|
||||
renderChatVoiceError,
|
||||
renderMicrophoneActivity,
|
||||
@@ -74,15 +75,6 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [
|
||||
"[role='listbox']",
|
||||
"[role='option']",
|
||||
].join(",");
|
||||
const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,audio/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," +
|
||||
".doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const LARGE_PASTE_TEXT_THRESHOLD = 1000;
|
||||
const LARGE_PASTE_TEXT_MIME_TYPE = "text/plain";
|
||||
const LARGE_PASTE_TEXT_FILE_PREFIX = "pasted-text-";
|
||||
const PASTED_TEXT_PREVIEW_MAX_LENGTH = 20;
|
||||
const largePastedTextAttachments = new WeakSet<ChatAttachment>();
|
||||
const pastedTextPreviews = new WeakMap<ChatAttachment, string>();
|
||||
|
||||
type ChatComposerProps = {
|
||||
paneId: string;
|
||||
@@ -946,20 +938,6 @@ function renderSlashMenu(
|
||||
`;
|
||||
}
|
||||
|
||||
type ChatAttachmentControlsProps = {
|
||||
attachments?: ChatAttachment[];
|
||||
getAttachments?: () => ChatAttachment[];
|
||||
draft?: string;
|
||||
getDraft?: () => string;
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
onDraftChange?: (next: string) => void;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[] {
|
||||
return props.getAttachments?.() ?? props.attachments ?? [];
|
||||
}
|
||||
|
||||
type ChatQueueProps = {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
@@ -1077,355 +1055,6 @@ function renderChatQueueItem(item: ChatQueueItem, props: ChatQueueProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function isSupportedChatAttachmentFile(file: Pick<File, "name" | "type">): boolean {
|
||||
if (file.type.startsWith("video/")) {
|
||||
return false;
|
||||
}
|
||||
return !/\.(?:avi|m4v|mov|mp4|mpeg|mpg|webm)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function clickComposerInput(target: HTMLElement, selector: string) {
|
||||
target.closest("details")?.removeAttribute("open");
|
||||
target.closest(".agent-chat__composer-shell")?.querySelector<HTMLInputElement>(selector)?.click();
|
||||
}
|
||||
|
||||
function clickComposerFileInput(target: HTMLElement) {
|
||||
clickComposerInput(target, ".agent-chat__file-input");
|
||||
}
|
||||
|
||||
function clickComposerPhotoInput(target: HTMLElement) {
|
||||
clickComposerInput(target, ".agent-chat__photo-input");
|
||||
}
|
||||
|
||||
function clickComposerCameraInput(target: HTMLElement) {
|
||||
clickComposerInput(target, ".agent-chat__camera-input");
|
||||
}
|
||||
|
||||
function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment {
|
||||
const attachment = {
|
||||
id: generateAttachmentId(),
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
fileName: file.name || undefined,
|
||||
sizeBytes: file.size,
|
||||
};
|
||||
return registerChatAttachmentPayload({ attachment, dataUrl, file });
|
||||
}
|
||||
|
||||
function isLargePastedTextAttachment(attachment: ChatAttachment): boolean {
|
||||
return largePastedTextAttachments.has(attachment);
|
||||
}
|
||||
|
||||
function encodeTextAsDataUrl(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const chunks: string[] = [];
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||
}
|
||||
return `data:${LARGE_PASTE_TEXT_MIME_TYPE};base64,${btoa(chunks.join(""))}`;
|
||||
}
|
||||
|
||||
function createLargePastedTextAttachment(text: string): ChatAttachment {
|
||||
const file = new File([text], `${LARGE_PASTE_TEXT_FILE_PREFIX}${Date.now()}.txt`, {
|
||||
type: LARGE_PASTE_TEXT_MIME_TYPE,
|
||||
});
|
||||
const attachment = chatAttachmentFromFile(file, encodeTextAsDataUrl(text));
|
||||
largePastedTextAttachments.add(attachment);
|
||||
const preview = compactPastedTextPreview(text);
|
||||
if (preview) {
|
||||
pastedTextPreviews.set(attachment, preview);
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function readTextFromDataUrl(dataUrl: string): string | null {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const metadata = match[1];
|
||||
const payload = match[2];
|
||||
if (metadata === undefined || payload === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (metadata.toLowerCase().includes(";base64")) {
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(payload.replace(/\+/g, "%20"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactPastedTextPreview(text: string): string | null {
|
||||
const normalized = text.replace(/\s+/gu, " ").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length <= PASTED_TEXT_PREVIEW_MAX_LENGTH) {
|
||||
return normalized;
|
||||
}
|
||||
return `${truncateUtf16Safe(normalized, PASTED_TEXT_PREVIEW_MAX_LENGTH).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function pastedTextPreview(attachment: ChatAttachment): string {
|
||||
return pastedTextPreviews.get(attachment) ?? attachment.fileName ?? "Attached file";
|
||||
}
|
||||
|
||||
function appendPastedTextToDraft(draft: string, text: string): string {
|
||||
if (!draft.trim()) {
|
||||
return text;
|
||||
}
|
||||
return `${draft.replace(/\s+$/u, "")}\n\n${text}`;
|
||||
}
|
||||
|
||||
function handleLargeTextPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps): boolean {
|
||||
if (!props.onAttachmentsChange) {
|
||||
return false;
|
||||
}
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
if (!text || text.length <= LARGE_PASTE_TEXT_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
e.preventDefault();
|
||||
const attachment = createLargePastedTextAttachment(text);
|
||||
props.onAttachmentsChange([...currentAttachments(props), attachment]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataImageClipboardFile(
|
||||
dataUrl: string,
|
||||
baseName = "pasted-image",
|
||||
): { file: File; dataUrl: string } | null {
|
||||
const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const mimeType = match[1]?.toLowerCase();
|
||||
const base64Source = match[2];
|
||||
if (!mimeType || !base64Source) {
|
||||
return null;
|
||||
}
|
||||
if (!isSupportedChatAttachmentFile({ name: baseName, type: mimeType })) {
|
||||
return null;
|
||||
}
|
||||
const base64 = base64Source.replace(/\s+/g, "");
|
||||
try {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png";
|
||||
return {
|
||||
file: new File([bytes], `${baseName}.${extension}`, { type: mimeType }),
|
||||
dataUrl: `data:${mimeType};base64,${base64}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a registered chat attachment from a base64 image data URL (e.g. a browser-panel annotation). */
|
||||
export function chatAttachmentFromDataUrl(
|
||||
dataUrl: string,
|
||||
fileName: string,
|
||||
): ChatAttachment | null {
|
||||
const baseName = fileName.replace(/\.[a-z0-9]+$/i, "") || "image";
|
||||
const parsed = dataImageClipboardFile(dataUrl, baseName);
|
||||
return parsed ? chatAttachmentFromFile(parsed.file, parsed.dataUrl) : null;
|
||||
}
|
||||
|
||||
function isImageAttachment(att: ChatAttachment): boolean {
|
||||
return att.mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
function handleChatAttachmentPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
imageItems.push(item);
|
||||
}
|
||||
}
|
||||
if (imageItems.length === 0) {
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
const pasted = text ? dataImageClipboardFile(text) : null;
|
||||
if (!pasted) {
|
||||
handleLargeTextPaste(e, props);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
props.onAttachmentsChange([
|
||||
...currentAttachments(props),
|
||||
chatAttachmentFromFile(pasted.file, pasted.dataUrl),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const newAttachment = chatAttachmentFromFile(file, dataUrl);
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), newAttachment]);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showPastedTextInComposer(att: ChatAttachment, props: ChatAttachmentControlsProps): void {
|
||||
const dataUrl = getChatAttachmentDataUrl(att);
|
||||
const text = dataUrl ? readTextFromDataUrl(dataUrl) : null;
|
||||
if (!text || !props.onDraftChange) {
|
||||
return;
|
||||
}
|
||||
const nextAttachments = currentAttachments(props).filter(
|
||||
(attachment) => attachment.id !== att.id,
|
||||
);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(nextAttachments);
|
||||
props.onDraftChange(appendPastedTextToDraft(props.getDraft?.() ?? props.draft ?? "", text));
|
||||
props.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const additions: ChatAttachment[] = [];
|
||||
let pending = 0;
|
||||
for (const file of input.files) {
|
||||
if (!isSupportedChatAttachmentFile(file)) {
|
||||
continue;
|
||||
}
|
||||
pending++;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
additions.push(chatAttachmentFromFile(file, reader.result as string));
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), ...additions]);
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentControlsProps) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
const additions: ChatAttachment[] = [];
|
||||
let pending = 0;
|
||||
for (const file of files) {
|
||||
if (!isSupportedChatAttachmentFile(file)) {
|
||||
continue;
|
||||
}
|
||||
pending++;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
additions.push(chatAttachmentFromFile(file, reader.result as string));
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
props.onAttachmentsChange?.([...currentAttachments(props), ...additions]);
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-attachments-preview">
|
||||
${attachments.map(
|
||||
(att) => html`
|
||||
<div
|
||||
class=${[
|
||||
"chat-attachment-thumb",
|
||||
isImageAttachment(att) ? "" : "chat-attachment-thumb--file",
|
||||
isLargePastedTextAttachment(att) ? "chat-attachment-thumb--pasted-text" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
${isImageAttachment(att) && getChatAttachmentPreviewUrl(att)
|
||||
? html`<img src=${getChatAttachmentPreviewUrl(att)!} alt="Attachment preview" />`
|
||||
: isLargePastedTextAttachment(att)
|
||||
? html`
|
||||
<div class="chat-attachment-file chat-attachment-file--pasted-text">
|
||||
<span class="chat-attachment-file__icon">${icons.fileText}</span>
|
||||
<span class="chat-attachment-file__body">
|
||||
<span class="chat-attachment-file__name">${pastedTextPreview(att)}</span>
|
||||
<button
|
||||
class="chat-attachment-text-action"
|
||||
type="button"
|
||||
aria-label=${t("worktrees.restore")}
|
||||
@click=${() => showPastedTextInComposer(att, props)}
|
||||
>
|
||||
${t("worktrees.restore")}
|
||||
<span aria-hidden="true">${icons.chevronRight}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<openclaw-tooltip .content=${att.fileName ?? "Attached file"}>
|
||||
<div class="chat-attachment-file">
|
||||
<span class="chat-attachment-file__icon">${icons.paperclip}</span>
|
||||
<span class="chat-attachment-file__name"
|
||||
>${att.fileName ?? "Attached file"}</span
|
||||
>
|
||||
</div>
|
||||
</openclaw-tooltip>
|
||||
`}
|
||||
<openclaw-tooltip .content=${t("chat.composer.removeAttachment")}>
|
||||
<button
|
||||
class="chat-attachment-remove"
|
||||
type="button"
|
||||
aria-label=${t("chat.composer.removeAttachment")}
|
||||
@click=${() => {
|
||||
const next = currentAttachments(props).filter((a) => a.id !== att.id);
|
||||
releaseChatAttachmentPayload(att.id);
|
||||
props.onAttachmentsChange?.(next);
|
||||
}}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
type ComposerRunStatus =
|
||||
| ChatRunUiStatus
|
||||
| {
|
||||
@@ -2564,43 +2193,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=${CHAT_ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
class="agent-chat__file-input"
|
||||
?disabled=${!canCompose}
|
||||
@change=${(event: Event) => {
|
||||
if (canCompose) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="agent-chat__photo-input"
|
||||
?disabled=${!canCompose}
|
||||
@change=${(event: Event) => {
|
||||
if (canCompose) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="agent-chat__camera-input"
|
||||
?disabled=${!canCompose}
|
||||
@change=${(event: Event) => {
|
||||
if (canCompose) {
|
||||
handleChatAttachmentFileSelect(event, props);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
|
||||
${renderChatVoiceError({
|
||||
status: props.realtimeTalkStatus,
|
||||
detail: props.realtimeTalkDetail,
|
||||
@@ -2608,55 +2201,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
})}
|
||||
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<wa-dropdown
|
||||
class="agent-chat__attach-menu"
|
||||
placement="top-start"
|
||||
aria-label=${t("chat.composer.addAttachment")}
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const menu = event.currentTarget as HTMLElement;
|
||||
switch (event.detail.item.value) {
|
||||
case "camera":
|
||||
clickComposerCameraInput(menu);
|
||||
break;
|
||||
case "photo":
|
||||
clickComposerPhotoInput(menu);
|
||||
break;
|
||||
case "file":
|
||||
clickComposerFileInput(menu);
|
||||
break;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
class="agent-chat__input-btn agent-chat__input-btn--attach"
|
||||
aria-label=${t("chat.composer.addAttachment")}
|
||||
?disabled=${!canCompose}
|
||||
title=${t("chat.composer.addAttachment")}
|
||||
@pointerdown=${(event: PointerEvent) => {
|
||||
if (document.activeElement === composerTextarea) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
|
||||
<span slot="icon" aria-hidden="true">${icons.camera}</span>
|
||||
<span>${t("chat.composer.takePhoto")}</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
|
||||
<span slot="icon" aria-hidden="true">${icons.image}</span>
|
||||
<span>${t("chat.composer.attachPhoto")}</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
|
||||
<span slot="icon" aria-hidden="true">${icons.folder}</span>
|
||||
<span>${t("chat.composer.attachFileOption")}</span>
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
${ref((element) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveChatThinkingSelectState,
|
||||
} from "../../../lib/chat/thinking.ts";
|
||||
import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts";
|
||||
import { selectChatModelProvider } from "./chat-model-provider-menu.ts";
|
||||
|
||||
export type ChatModelControlsProps = {
|
||||
activeRunId: string | null;
|
||||
@@ -36,6 +37,7 @@ export type ChatModelControlsProps = {
|
||||
modelSelectionRuntimeId?: string;
|
||||
modelSwitching: boolean;
|
||||
modelsLoading?: boolean;
|
||||
mode?: "combined" | "model";
|
||||
sending: boolean;
|
||||
sessionKey: string;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
@@ -131,26 +133,6 @@ function resolveChatModelPickerLabel(
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
function selectChatModelProvider(event: MouseEvent, provider: string): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const menu = (event.currentTarget as HTMLElement).closest(
|
||||
".chat-controls__inline-select-menu--combined",
|
||||
);
|
||||
if (!(menu instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
menu.querySelectorAll<HTMLElement>("[data-chat-model-provider]").forEach((button) => {
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
button.dataset.chatModelProvider === provider ? "true" : "false",
|
||||
);
|
||||
});
|
||||
menu.querySelectorAll<HTMLElement>("[data-chat-model-provider-group]").forEach((group) => {
|
||||
group.hidden = group.dataset.chatModelProviderGroup !== provider;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
const {
|
||||
currentOverride,
|
||||
@@ -257,6 +239,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
disabled,
|
||||
fastMode,
|
||||
modelSelectionLocked: props.modelSelectionLocked === true,
|
||||
modelOnly: props.mode === "model",
|
||||
modelOptions,
|
||||
onRequestUpdate: props.onRequestUpdate,
|
||||
selectedModelValue: currentOverride,
|
||||
@@ -302,6 +285,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
fastMode: ChatFastModeSelectState;
|
||||
disabled: boolean;
|
||||
modelSelectionLocked: boolean;
|
||||
modelOnly: boolean;
|
||||
modelOptions: ChatModelProviderOption[];
|
||||
selectedModelValue: string;
|
||||
selectedThinkingValue: string;
|
||||
@@ -320,6 +304,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
disabled,
|
||||
fastMode,
|
||||
modelSelectionLocked,
|
||||
modelOnly,
|
||||
modelOptions,
|
||||
selectedModelValue,
|
||||
selectedThinkingValue,
|
||||
@@ -336,7 +321,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
} = params;
|
||||
const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel);
|
||||
const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel);
|
||||
const triggerTitle = `${triggerModel} · ${triggerThinking}`;
|
||||
const triggerTitle = modelOnly ? triggerModel : `${triggerModel} · ${triggerThinking}`;
|
||||
const triggerLabel = triggerTitle;
|
||||
const sliderStops = thinkingOptions.filter((option) => option.value !== "");
|
||||
const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue);
|
||||
@@ -427,7 +412,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined;
|
||||
const effectiveThinkingValue = selectedThinkingValue || thinkingDefaultValue;
|
||||
const onlyStopSelected = onlyStop?.value === effectiveThinkingValue;
|
||||
const showReasoningPanel = true;
|
||||
const showReasoningPanel = !modelOnly;
|
||||
const providerGroups = new Map<string, ChatModelProviderOption[]>();
|
||||
for (const option of modelOptions) {
|
||||
const existing = providerGroups.get(option.provider);
|
||||
@@ -513,11 +498,13 @@ function renderChatModelReasoningSelect(params: {
|
||||
: ""}"
|
||||
data-chat-model-select="true"
|
||||
data-chat-model-locked=${modelSelectionLocked ? "true" : "false"}
|
||||
data-chat-thinking-select="true"
|
||||
data-chat-thinking-select=${modelOnly ? nothing : "true"}
|
||||
data-chat-select-value=${selectedModelValue}
|
||||
data-chat-thinking-value=${selectedThinkingValue}
|
||||
data-chat-thinking-disabled=${thinkingDisabled ? "true" : "false"}
|
||||
aria-label=${`${t("chat.selectors.model")}, ${t("chat.selectors.thinkingLevel")}: ${triggerTitle}`}
|
||||
aria-label=${modelOnly
|
||||
? `${t("chat.selectors.model")}: ${triggerTitle}`
|
||||
: `${t("chat.selectors.model")}, ${t("chat.selectors.thinkingLevel")}: ${triggerTitle}`}
|
||||
aria-disabled=${disabled ? "true" : "false"}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (disabled) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function selectChatModelProvider(event: MouseEvent, provider: string): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const menu = (event.currentTarget as HTMLElement).closest(
|
||||
".chat-controls__inline-select-menu--combined",
|
||||
);
|
||||
if (!(menu instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
menu.querySelectorAll<HTMLElement>("[data-chat-model-provider]").forEach((button) => {
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
button.dataset.chatModelProvider === provider ? "true" : "false",
|
||||
);
|
||||
});
|
||||
menu.querySelectorAll<HTMLElement>("[data-chat-model-provider-group]").forEach((group) => {
|
||||
group.hidden = group.dataset.chatModelProviderGroup !== provider;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
||||
import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts";
|
||||
import {
|
||||
markLocalRecoveryItem,
|
||||
markVolatileQueuedMessage,
|
||||
readChatQueueForScope,
|
||||
type ChatQueueScopedSessionHost,
|
||||
writeChatQueueForScope,
|
||||
} from "./chat-queue.ts";
|
||||
|
||||
const INITIAL_TURN_HANDOFF_TTL_MS = 60_000;
|
||||
|
||||
type InitialTurnHandoff = {
|
||||
item: ChatQueueItem;
|
||||
sessionKey: string;
|
||||
timer: ReturnType<typeof globalThis.setTimeout>;
|
||||
};
|
||||
|
||||
let pending: InitialTurnHandoff | null = null;
|
||||
|
||||
function clearPending(releaseAttachments: boolean): void {
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
globalThis.clearTimeout(pending.timer);
|
||||
if (releaseAttachments) {
|
||||
releaseChatAttachmentPayloads(pending.item.attachments ?? []);
|
||||
}
|
||||
pending = null;
|
||||
}
|
||||
|
||||
/** Hands one storage-rejected initial turn to the chat route that owns its created session. */
|
||||
export function prepareInitialTurnHandoff(sessionKey: string, item: ChatQueueItem): void {
|
||||
clearPending(true);
|
||||
const timer = globalThis.setTimeout(() => clearPending(true), INITIAL_TURN_HANDOFF_TTL_MS);
|
||||
pending = { item, sessionKey, timer };
|
||||
}
|
||||
|
||||
export function consumeInitialTurnHandoff(sessionKey: string): ChatQueueItem | null {
|
||||
if (!pending || !areUiSessionKeysEquivalent(pending.sessionKey, sessionKey)) {
|
||||
return null;
|
||||
}
|
||||
const item = pending.item;
|
||||
clearPending(false);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function admitInitialTurnHandoff(
|
||||
host: ChatQueueScopedSessionHost,
|
||||
sessionKey: string,
|
||||
): boolean {
|
||||
const item = consumeInitialTurnHandoff(sessionKey);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
const queue = readChatQueueForScope(host, sessionKey, item.agentId);
|
||||
if (!queue.some((entry) => entry.id === item.id)) {
|
||||
writeChatQueueForScope(host, sessionKey, [...queue, item], item.agentId);
|
||||
}
|
||||
markLocalRecoveryItem(host, item.id);
|
||||
markVolatileQueuedMessage(host, item.id);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
||||
|
||||
const liveTerminalRunIds = new WeakMap<object, string>();
|
||||
const authoritativeTerminals = new WeakMap<object, AuthoritativeTerminal>();
|
||||
|
||||
type AuthoritativeTerminal = {
|
||||
historyApplied: boolean;
|
||||
messageId: string;
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
/** Associates a live terminal projection with its run without altering transcript bytes. */
|
||||
export function rememberLiveTerminalRun(
|
||||
message: unknown,
|
||||
runId: string | null | undefined,
|
||||
): unknown {
|
||||
if (runId && message && typeof message === "object") {
|
||||
liveTerminalRunIds.set(message, runId);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function isLiveTerminalForRun(message: unknown, runId: string): boolean {
|
||||
return Boolean(
|
||||
message && typeof message === "object" && liveTerminalRunIds.get(message) === runId,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearAuthoritativeTerminal(host: object): void {
|
||||
authoritativeTerminals.delete(host);
|
||||
}
|
||||
|
||||
function readTerminalAssistantMessageIdentity(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const message = record.message;
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
Array.isArray(message) ||
|
||||
(message as Record<string, unknown>).role !== "assistant"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return typeof record.messageId === "string" && record.messageId.trim() ? record.messageId : null;
|
||||
}
|
||||
|
||||
export function rememberAuthoritativeTerminal(options: {
|
||||
event: {
|
||||
clientRunId?: string | null;
|
||||
hasActiveRun?: boolean | null;
|
||||
key: string;
|
||||
runId?: string | null;
|
||||
};
|
||||
host: object;
|
||||
matchesChat: boolean;
|
||||
payload: unknown;
|
||||
runIdBeforeApply: string | null;
|
||||
}): void {
|
||||
const messageId = readTerminalAssistantMessageIdentity(options.payload);
|
||||
if (
|
||||
!options.runIdBeforeApply ||
|
||||
!options.matchesChat ||
|
||||
options.event.hasActiveRun === true ||
|
||||
!messageId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
authoritativeTerminals.set(options.host, {
|
||||
historyApplied: false,
|
||||
messageId,
|
||||
runId: options.event.clientRunId ?? options.event.runId ?? options.runIdBeforeApply,
|
||||
sessionKey: options.event.key,
|
||||
});
|
||||
}
|
||||
|
||||
function messageOpenClawId(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return null;
|
||||
}
|
||||
const meta = (message as Record<string, unknown>)["__openclaw"];
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return null;
|
||||
}
|
||||
const value = (meta as Record<string, unknown>)["id"];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
export function reconcileAuthoritativeTerminalHistory<T>(options: {
|
||||
currentMessages: T[];
|
||||
host: object;
|
||||
previousMessages: T[];
|
||||
sessionKey: string;
|
||||
visibleMessages: T[];
|
||||
}): { currentMessages: T[]; previousMessages: T[] } {
|
||||
const terminal = authoritativeTerminals.get(options.host);
|
||||
const historyContainsTerminal = Boolean(
|
||||
terminal &&
|
||||
areUiSessionKeysEquivalent(terminal.sessionKey, options.sessionKey) &&
|
||||
options.visibleMessages.some((message) => messageOpenClawId(message) === terminal.messageId),
|
||||
);
|
||||
if (!terminal || !historyContainsTerminal) {
|
||||
return options;
|
||||
}
|
||||
authoritativeTerminals.set(options.host, { ...terminal, historyApplied: true });
|
||||
return {
|
||||
currentMessages: options.currentMessages.filter(
|
||||
(message) => !isLiveTerminalForRun(message, terminal.runId),
|
||||
),
|
||||
previousMessages: options.previousMessages.filter(
|
||||
(message) => !isLiveTerminalForRun(message, terminal.runId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function authoritativeHistoryAppliedForRun(host: object, runId: string): boolean {
|
||||
const terminal = authoritativeTerminals.get(host);
|
||||
return terminal?.runId === runId && terminal.historyApplied;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { releaseChatAttachmentPayloads } from "../chat/attachment-payload-store.ts";
|
||||
|
||||
export class NewSessionAttachmentDraft {
|
||||
attachments: ChatAttachment[] = [];
|
||||
pendingReads = 0;
|
||||
private readController = new AbortController();
|
||||
|
||||
constructor(private readonly notify: () => void) {}
|
||||
|
||||
get readSignal() {
|
||||
return this.readController.signal;
|
||||
}
|
||||
|
||||
replace(attachments: ChatAttachment[]) {
|
||||
this.attachments = attachments;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
updatePending(readSignal: AbortSignal, delta: 1 | -1) {
|
||||
if (this.readController.signal !== readSignal) {
|
||||
return;
|
||||
}
|
||||
this.pendingReads = Math.max(0, this.pendingReads + delta);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
abortReads() {
|
||||
this.readController.abort();
|
||||
this.readController = new AbortController();
|
||||
this.pendingReads = 0;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
reset(options: { release: boolean }) {
|
||||
this.abortReads();
|
||||
if (options.release) {
|
||||
releaseChatAttachmentPayloads(this.attachments);
|
||||
}
|
||||
this.attachments = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearAfterSubmit(release: boolean) {
|
||||
if (release) {
|
||||
releaseChatAttachmentPayloads(this.attachments);
|
||||
}
|
||||
this.attachments = [];
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
handleChatAttachmentPaste,
|
||||
renderAttachmentPreview,
|
||||
renderChatAttachmentInputs,
|
||||
renderChatAttachmentMenu,
|
||||
} from "../chat/components/chat-attachments.ts";
|
||||
import type { NewSessionAttachmentDraft } from "./attachment-draft.ts";
|
||||
import type { NewSessionModelControl } from "./model-control.ts";
|
||||
|
||||
type NewSessionComposerOptions = {
|
||||
attachments: ChatAttachment[];
|
||||
canSubmit: boolean;
|
||||
getAttachments: () => ChatAttachment[];
|
||||
message: string;
|
||||
modelControl?: TemplateResult | typeof nothing;
|
||||
pendingAttachmentReads: number;
|
||||
readSignal: AbortSignal;
|
||||
requiresModifier: boolean;
|
||||
submitting: boolean;
|
||||
onAttachmentsChange: (attachments: ChatAttachment[]) => void;
|
||||
onPendingReadsChange: (delta: 1 | -1) => void;
|
||||
onInput: (message: string) => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
@@ -31,34 +47,108 @@ function handleComposerKeydown(event: KeyboardEvent, options: NewSessionComposer
|
||||
/** Draft message box styled as the chat composer shell so both pickers match. */
|
||||
export function renderNewSessionComposer(options: NewSessionComposerOptions) {
|
||||
const startLabel = options.submitting ? t("newSession.starting") : t("newSession.start");
|
||||
const attachmentProps = {
|
||||
attachments: options.attachments,
|
||||
disabled: options.submitting,
|
||||
getAttachments: options.getAttachments,
|
||||
draft: options.message,
|
||||
getDraft: () => options.message,
|
||||
onAttachmentsChange: options.onAttachmentsChange,
|
||||
onDraftChange: options.onInput,
|
||||
onPendingReadsChange: options.onPendingReadsChange,
|
||||
readSignal: options.readSignal,
|
||||
};
|
||||
return html`
|
||||
<div class="agent-chat__input new-session-page__composer">
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
class="new-session-page__message"
|
||||
rows="3"
|
||||
?disabled=${options.submitting}
|
||||
placeholder=${t("newSession.messagePlaceholder")}
|
||||
.value=${options.message}
|
||||
@input=${(event: Event) => options.onInput((event.target as HTMLTextAreaElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => handleComposerKeydown(event, options)}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">
|
||||
<openclaw-tooltip content=${t("newSession.start")}>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-send-btn"
|
||||
?disabled=${!options.canSubmit}
|
||||
aria-label=${startLabel}
|
||||
@click=${options.onSubmit}
|
||||
>
|
||||
${options.submitting ? icons.loader : icons.arrowUp}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<div class="agent-chat__composer-shell new-session-page__composer">
|
||||
<div class="agent-chat__input">
|
||||
${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)}
|
||||
<div class="agent-chat__composer-input-row">
|
||||
${renderChatAttachmentMenu(attachmentProps)}
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
class="new-session-page__message"
|
||||
rows="3"
|
||||
?disabled=${options.submitting}
|
||||
placeholder=${t("newSession.messagePlaceholder")}
|
||||
.value=${options.message}
|
||||
@input=${(event: Event) =>
|
||||
options.onInput((event.target as HTMLTextAreaElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => handleComposerKeydown(event, options)}
|
||||
@paste=${(event: ClipboardEvent) => {
|
||||
if (!options.submitting) {
|
||||
handleChatAttachmentPaste(event, attachmentProps);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="agent-chat__composer-actions">
|
||||
<openclaw-tooltip content=${t("newSession.start")}>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-send-btn"
|
||||
?disabled=${!options.canSubmit}
|
||||
aria-label=${startLabel}
|
||||
@click=${options.onSubmit}
|
||||
>
|
||||
${options.submitting ? icons.loader : icons.arrowUp}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
${options.modelControl && options.modelControl !== nothing
|
||||
? html`<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-controls">${options.modelControl}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
${options.pendingAttachmentReads > 0
|
||||
? html`<span class="agent-chat__sr-only" role="status"
|
||||
>${t("newSession.readingAttachment")}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderNewSessionDraftComposer(options: {
|
||||
agentDefaultModel?: string;
|
||||
agentId: string;
|
||||
attachmentDraft: NewSessionAttachmentDraft;
|
||||
canSubmit: boolean;
|
||||
context: import("../../app/context.ts").ApplicationContext | undefined;
|
||||
isCatalogTarget: boolean;
|
||||
message: string;
|
||||
modelControl: NewSessionModelControl;
|
||||
requiresModifier: boolean;
|
||||
submitting: boolean;
|
||||
onInput: (message: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const readSignal = options.attachmentDraft.readSignal;
|
||||
return renderNewSessionComposer({
|
||||
attachments: options.attachmentDraft.attachments,
|
||||
canSubmit: options.canSubmit,
|
||||
getAttachments: () => options.attachmentDraft.attachments,
|
||||
message: options.message,
|
||||
modelControl: options.isCatalogTarget
|
||||
? nothing
|
||||
: options.modelControl.render({
|
||||
agentDefaultModel: options.agentDefaultModel,
|
||||
agentId: options.agentId,
|
||||
context: options.context,
|
||||
sending: options.submitting,
|
||||
}),
|
||||
pendingAttachmentReads: options.attachmentDraft.pendingReads,
|
||||
readSignal,
|
||||
requiresModifier: options.requiresModifier,
|
||||
submitting: options.submitting,
|
||||
onAttachmentsChange: (attachments) => {
|
||||
if (!options.submitting) {
|
||||
options.attachmentDraft.replace(attachments);
|
||||
}
|
||||
},
|
||||
onPendingReadsChange: (delta) => options.attachmentDraft.updatePending(readSignal, delta),
|
||||
onInput: options.onInput,
|
||||
onSubmit: options.onSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,51 @@ describe("buildDraftSessionCreateParams", () => {
|
||||
).toEqual({ agentId: "main", message: "hello" });
|
||||
});
|
||||
|
||||
it("includes initial-message attachments", () => {
|
||||
const attachments = [
|
||||
{ type: "image", mimeType: "image/png", fileName: "pixel.png", content: "aGVsbG8=" },
|
||||
];
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
agentId: "main",
|
||||
message: "",
|
||||
attachments,
|
||||
worktree: false,
|
||||
}),
|
||||
).toEqual({ agentId: "main", message: "", attachments });
|
||||
});
|
||||
|
||||
it("includes a selected model for a plain session", () => {
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
agentId: "main",
|
||||
message: "use the selected model",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
worktree: false,
|
||||
}),
|
||||
).toEqual({
|
||||
agentId: "main",
|
||||
message: "use the selected model",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not combine a catalog target with a draft model override", () => {
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
agentId: "main",
|
||||
message: "start coding",
|
||||
model: "openai/gpt-5.5",
|
||||
worktree: false,
|
||||
catalogId: "claude",
|
||||
}),
|
||||
).toEqual({
|
||||
agentId: "main",
|
||||
message: "start coding",
|
||||
catalogId: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits the catalog target for server-side resolution", () => {
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
||||
|
||||
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export function isWorktreeNameValid(value: string): boolean {
|
||||
const name = value.trim();
|
||||
return !name || WORKTREE_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** Maps the new-session draft selections onto additive sessions.create params. */
|
||||
export function buildDraftSessionCreateParams(draft: {
|
||||
agentId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
attachments?: unknown[];
|
||||
worktree: boolean;
|
||||
baseRef?: string;
|
||||
worktreeName?: string;
|
||||
@@ -17,11 +26,14 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
const workspace = normalizeOptionalString(draft.workspace);
|
||||
const execNode = normalizeOptionalString(draft.execNode);
|
||||
const catalogId = normalizeOptionalString(draft.catalogId);
|
||||
const model = normalizeOptionalString(draft.model);
|
||||
const customFolder = cwd && cwd !== workspace ? cwd : undefined;
|
||||
return {
|
||||
agentId: normalizeAgentId(draft.agentId),
|
||||
message: draft.message,
|
||||
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
|
||||
...(catalogId ? { catalogId } : {}),
|
||||
...(!catalogId && model ? { model } : {}),
|
||||
...(draft.worktree
|
||||
? {
|
||||
worktree: true,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ModelCatalogEntry } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { renderChatModelControls } from "../chat/components/chat-model-controls.ts";
|
||||
|
||||
export class NewSessionModelControl {
|
||||
private requestToken = 0;
|
||||
private catalog: ModelCatalogEntry[] = [];
|
||||
private loading = false;
|
||||
selected = "";
|
||||
|
||||
constructor(private readonly notify: () => void) {}
|
||||
|
||||
invalidate(resetSelection = false) {
|
||||
this.requestToken += 1;
|
||||
this.loading = false;
|
||||
this.catalog = [];
|
||||
if (resetSelection) {
|
||||
this.selected = "";
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.invalidate(true);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
load(context: ApplicationContext | undefined, agentId: string, enabled: boolean) {
|
||||
const snapshot = context?.gateway.snapshot;
|
||||
const client = snapshot?.client;
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const requestId = ++this.requestToken;
|
||||
this.catalog = [];
|
||||
if (!snapshot?.connected || !client || !normalizedAgentId || !enabled) {
|
||||
this.loading = false;
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.notify();
|
||||
void client
|
||||
.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", {
|
||||
agentId: normalizedAgentId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (requestId === this.requestToken) {
|
||||
this.catalog = Array.isArray(result.models) ? result.models : [];
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === this.requestToken) {
|
||||
this.catalog = [];
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === this.requestToken) {
|
||||
this.loading = false;
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(options: {
|
||||
agentDefaultModel?: string;
|
||||
agentId: string;
|
||||
context: ApplicationContext | undefined;
|
||||
sending: boolean;
|
||||
}) {
|
||||
const snapshot = options.context?.gateway.snapshot;
|
||||
const sessionKey = `new-session:${normalizeAgentId(options.agentId)}`;
|
||||
return renderChatModelControls({
|
||||
activeRunId: null,
|
||||
agentDefaultModel: options.agentDefaultModel,
|
||||
connected: snapshot?.connected === true,
|
||||
gatewayAvailable: Boolean(snapshot?.client),
|
||||
loading: false,
|
||||
modelCatalog: this.catalog,
|
||||
modelOverrides: { [sessionKey]: this.selected },
|
||||
modelSwitching: false,
|
||||
modelsLoading: this.loading,
|
||||
mode: "model",
|
||||
sending: options.sending,
|
||||
sessionKey,
|
||||
sessionsResult: options.context?.sessions.state.result ?? null,
|
||||
stream: null,
|
||||
onModelSelect: (value) => {
|
||||
this.selected = value;
|
||||
},
|
||||
onRequestUpdate: this.notify,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,16 @@ import { t } from "../../i18n/index.ts";
|
||||
import { searchForSession } from "../../lib/sessions/index.ts";
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import "../../styles/chat.css";
|
||||
import "../../styles/new-session.css";
|
||||
import { buildChatApiAttachments } from "../chat/attachment-api.ts";
|
||||
import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
|
||||
import { admitStoredChatComposerQueueItem } from "../chat/composer-persistence.ts";
|
||||
import { NewSessionAttachmentDraft } from "./attachment-draft.ts";
|
||||
import * as catalog from "./catalog-target.ts";
|
||||
import { renderNewSessionComposer } from "./composer.ts";
|
||||
import { buildDraftSessionCreateParams } from "./create-params.ts";
|
||||
import { renderNewSessionDraftComposer } from "./composer.ts";
|
||||
import { buildDraftSessionCreateParams, isWorktreeNameValid } from "./create-params.ts";
|
||||
import {
|
||||
type BrowserTarget,
|
||||
type DraftBranches,
|
||||
@@ -32,9 +32,10 @@ import {
|
||||
readDraftNodes,
|
||||
} from "./discovery.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
import { NewSessionModelControl } from "./model-control.ts";
|
||||
import { folderDisplayName, isAbsolutePath } from "./path.ts";
|
||||
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
|
||||
|
||||
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const;
|
||||
|
||||
class NewSessionPage extends OpenClawLightDomElement {
|
||||
@@ -79,6 +80,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private branchesRequestToken = 0;
|
||||
private baseRefEditGeneration = 0;
|
||||
private browserRequestToken = 0;
|
||||
private readonly attachmentDraft = new NewSessionAttachmentDraft(() => this.requestUpdate());
|
||||
private readonly modelControl = new NewSessionModelControl(() => this.requestUpdate());
|
||||
private gatewaySource: ApplicationContext["gateway"] | null = null;
|
||||
private gatewayClient: ApplicationContext["gateway"]["snapshot"]["client"] = null;
|
||||
private gatewayConnected = false;
|
||||
@@ -131,6 +134,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.branches = null;
|
||||
this.baseRef = ""; // Never carry a derived ref across a transport epoch.
|
||||
this.agentsHydrated = false;
|
||||
this.modelControl.invalidate(resetHostSelection);
|
||||
this.attachmentDraft.abortReads();
|
||||
this.closeBrowser();
|
||||
this.invalidateSubmission(true); // Transport loss makes an in-flight create outcome unknowable.
|
||||
if (!resetHostSelection) {
|
||||
@@ -200,6 +205,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.subscriptions.clear();
|
||||
// This invalidates submitRequestToken before payload release below, so a
|
||||
// late sessions.create result cannot navigate with attachments we no longer own.
|
||||
this.invalidateGatewayDiscovery(true);
|
||||
this.gatewaySource = null;
|
||||
this.gatewayClient = null;
|
||||
@@ -209,6 +216,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.catalogRetryAttempt = 0;
|
||||
globalThis.clearTimeout(this.catalogRetryTimer);
|
||||
this.catalogRetryTimer = undefined;
|
||||
this.attachmentDraft.reset({ release: true });
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -307,6 +315,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.folderSelectedByUser = false;
|
||||
}
|
||||
void this.loadNodes();
|
||||
this.modelControl.load(this.context, this.agentId, !catalog.isTarget(this.data));
|
||||
this.maybeLoadBranches();
|
||||
}
|
||||
|
||||
@@ -323,6 +332,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.branchesLoading = false;
|
||||
this.execNode = "";
|
||||
this.message = "";
|
||||
this.modelControl.reset();
|
||||
this.attachmentDraft.reset({ release: true });
|
||||
this.error = null;
|
||||
this.wherePopoverHiding = false;
|
||||
this.folderPopoverHiding = false;
|
||||
@@ -442,7 +453,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
if (
|
||||
this.submitting ||
|
||||
this.submissionOutcomeUnknown ||
|
||||
!this.message.trim() ||
|
||||
this.attachmentDraft.pendingReads > 0 ||
|
||||
(!this.message.trim() && this.attachmentDraft.attachments.length === 0) ||
|
||||
!this.context?.gateway.snapshot.connected
|
||||
) {
|
||||
return false;
|
||||
@@ -470,8 +482,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
if (this.worktree && !this.worktreeAvailable()) {
|
||||
return false;
|
||||
}
|
||||
const name = this.worktreeName.trim();
|
||||
if (this.worktree && name && !WORKTREE_NAME_PATTERN.test(name)) {
|
||||
if (this.worktree && !isWorktreeNameValid(this.worktreeName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -483,6 +494,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const message = this.message.trim();
|
||||
const attachments = this.attachmentDraft.attachments;
|
||||
const requestId = ++this.submitRequestToken;
|
||||
this.submitting = true;
|
||||
this.error = null;
|
||||
@@ -500,6 +512,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
buildDraftSessionCreateParams({
|
||||
agentId: this.agentId,
|
||||
message,
|
||||
model: this.modelControl.selected,
|
||||
attachments: buildChatApiAttachments(attachments),
|
||||
worktree: this.worktree,
|
||||
baseRef: this.baseRef,
|
||||
worktreeName: this.worktreeName,
|
||||
@@ -516,36 +530,17 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
this.error = context.sessions.state.error ?? t("newSession.createFailed");
|
||||
return;
|
||||
}
|
||||
if (result.initialRun.status === "rejected") {
|
||||
const gateway = context.gateway.snapshot;
|
||||
const persisted = admitStoredChatComposerQueueItem(
|
||||
{
|
||||
settings: loadSettings(),
|
||||
assistantAgentId: gateway.assistantAgentId,
|
||||
agentsList: context.agents.state.agentsList,
|
||||
hello: gateway.hello,
|
||||
},
|
||||
result.key,
|
||||
{
|
||||
id: generateUUID(),
|
||||
text: message,
|
||||
createdAt: Date.now(),
|
||||
kind: "queued",
|
||||
refreshSessions: true,
|
||||
sendAttempts: 1,
|
||||
sendError: result.initialRun.error,
|
||||
sendState: "failed",
|
||||
sessionKey: result.key,
|
||||
agentId: normalizeAgentId(this.agentId),
|
||||
},
|
||||
);
|
||||
if (!persisted) {
|
||||
// Stay on the draft when browser storage is unavailable: preserving
|
||||
// the typed task takes priority over navigating to the partial session.
|
||||
this.error = result.initialRun.error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const handedOffAttachments =
|
||||
result.initialRun.status === "rejected" &&
|
||||
retainRejectedInitialTurn({
|
||||
agentId: this.agentId,
|
||||
attachments,
|
||||
context,
|
||||
error: result.initialRun.error,
|
||||
message,
|
||||
sessionKey: result.key,
|
||||
});
|
||||
this.attachmentDraft.clearAfterSubmit(!handedOffAttachments);
|
||||
context.gateway.setSessionKey(result.key);
|
||||
context.navigate("chat", { search: searchForSession(result.key) });
|
||||
} finally {
|
||||
@@ -565,12 +560,14 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
this.agentId = normalizeAgentId(agentId);
|
||||
this.modelControl.reset();
|
||||
this.agentSelectedByUser = true;
|
||||
this.folder = this.execNode ? "" : this.workspacePath();
|
||||
this.folderSelectedByUser = false;
|
||||
this.worktree = false;
|
||||
this.worktreeName = "";
|
||||
this.closeBrowser();
|
||||
this.modelControl.load(this.context, this.agentId, true);
|
||||
this.maybeLoadBranches();
|
||||
}
|
||||
|
||||
@@ -1191,10 +1188,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
|
||||
/** Target row + composer, rendered mid-screen between the hero and recents. */
|
||||
private renderDraftBlock() {
|
||||
const worktreeNameInvalid =
|
||||
this.worktree &&
|
||||
this.worktreeName.trim() !== "" &&
|
||||
!WORKTREE_NAME_PATTERN.test(this.worktreeName.trim());
|
||||
const worktreeNameInvalid = this.worktree && !isWorktreeNameValid(this.worktreeName);
|
||||
return html`
|
||||
<div class="new-session-page__draft" aria-busy=${String(this.submitting)}>
|
||||
${this.renderTargetBar()}
|
||||
@@ -1205,9 +1199,15 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
${this.submissionOutcomeUnknown
|
||||
? html`<div class="new-session-page__error">${t("newSession.createOutcomeUnknown")}</div>`
|
||||
: nothing}
|
||||
${renderNewSessionComposer({
|
||||
${renderNewSessionDraftComposer({
|
||||
agentDefaultModel: this.selectedAgent()?.model?.primary,
|
||||
agentId: this.agentId,
|
||||
attachmentDraft: this.attachmentDraft,
|
||||
canSubmit: this.canSubmit(),
|
||||
context: this.context,
|
||||
isCatalogTarget: catalog.isTarget(this.data),
|
||||
message: this.message,
|
||||
modelControl: this.modelControl,
|
||||
requiresModifier: loadSettings().chatSendShortcut === "modifier-enter",
|
||||
submitting: this.submitting,
|
||||
onInput: (message) => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { loadSettings } from "../../app/settings.ts";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import { admitStoredChatComposerQueueItem } from "../chat/composer-persistence.ts";
|
||||
import { prepareInitialTurnHandoff } from "../chat/initial-turn-handoff.ts";
|
||||
|
||||
/** Returns true when attachment payload ownership moved to the volatile handoff. */
|
||||
export function retainRejectedInitialTurn(options: {
|
||||
agentId: string;
|
||||
attachments: ChatAttachment[];
|
||||
context: ApplicationContext;
|
||||
error: string;
|
||||
message: string;
|
||||
sessionKey: string;
|
||||
}): boolean {
|
||||
const gateway = options.context.gateway.snapshot;
|
||||
const rejectedItem = {
|
||||
id: generateUUID(),
|
||||
text: options.message,
|
||||
attachments: options.attachments,
|
||||
createdAt: Date.now(),
|
||||
kind: "queued" as const,
|
||||
refreshSessions: true,
|
||||
sendAttempts: 1,
|
||||
sendError: options.error,
|
||||
sendState: "failed" as const,
|
||||
sessionKey: options.sessionKey,
|
||||
agentId: normalizeAgentId(options.agentId),
|
||||
};
|
||||
const persisted = admitStoredChatComposerQueueItem(
|
||||
{
|
||||
settings: loadSettings(),
|
||||
assistantAgentId: gateway.assistantAgentId,
|
||||
agentsList: options.context.agents.state.agentsList,
|
||||
hello: gateway.hello,
|
||||
},
|
||||
options.sessionKey,
|
||||
rejectedItem,
|
||||
);
|
||||
if (persisted) {
|
||||
return false;
|
||||
}
|
||||
// The server already created this key. A volatile handoff prevents retry
|
||||
// from creating a duplicate when large attachments exceed browser storage.
|
||||
prepareInitialTurnHandoff(options.sessionKey, {
|
||||
...rejectedItem,
|
||||
sendRunId: generateUUID(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -50,6 +50,13 @@ openclaw-new-session-page {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The welcome column starts beside the sidebar. Open the shared model menu
|
||||
into that column instead of letting its default right edge cover navigation. */
|
||||
.new-session-page__composer .chat-controls__inline-select-menu--combined {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Cursor-style quiet trigger row. Web Awesome owns popup/listbox behavior. */
|
||||
.new-session-page__triggers {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user