Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion desktop/renderer/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ interface AppSettings {
privacyLevel: string;
}

interface ChatAttachment {
type: "image" | "file";
mimeType: string;
fileName: string;
size: number;
content: string;
mediaPath?: string;
}

type AttachmentRejectionReason = "file_too_large" | "total_too_large" | "read_failed";

interface AttachmentRejection {
fileName: string;
reason: AttachmentRejectionReason;
size?: number;
limit?: number;
}

interface OpenFilesResult {
attachments: ChatAttachment[];
rejections: AttachmentRejection[];
}

type GitHubCopilotLoginEvent =
| {
sessionId: string;
Expand Down Expand Up @@ -182,7 +205,11 @@ interface OpenClawAPI {
};
chat: {
isConnected(): Promise<boolean>;
sendMessage(sessionKey: string, message: string): Promise<void>;
sendMessage(
sessionKey: string,
message: string,
attachments?: ChatAttachment[],
): Promise<void>;
loadHistory(sessionKey: string): Promise<{ messages?: unknown[]; thinkingLevel?: string }>;
abort(sessionKey: string): Promise<void>;
deleteSession(sessionKey: string): Promise<void>;
Expand Down Expand Up @@ -287,6 +314,12 @@ interface OpenClawAPI {
shell: {
openExternal(url: string): Promise<void>;
};
attachment: {
open(attachment: ChatAttachment): Promise<{ ok: boolean; error?: string }>;
};
dialog: {
openFiles(currentTotalBytes?: number): Promise<OpenFilesResult>;
};
sandbox: {
getStatus(): Promise<{
available: boolean;
Expand Down
84 changes: 84 additions & 0 deletions desktop/renderer/src/components/chat/ChatAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import ChatAttachments from "./ChatAttachments.vue";

const openAttachment = vi.fn().mockResolvedValue({ ok: true });

beforeEach(() => {
openAttachment.mockClear();
Object.defineProperty(window, "openclaw", {
configurable: true,
value: { attachment: { open: openAttachment } },
});
});

describe("ChatAttachments", () => {
const attachment: ChatAttachment = {
type: "file",
mimeType: "text/plain",
fileName: "notes.txt",
size: 5,
content: "aGVsbG8=",
};

it("opens a content-bearing attachment when its card is clicked", async () => {
const wrapper = mount(ChatAttachments, {
props: { attachments: [attachment] },
});

await wrapper.get(".chat-attachment").trigger("click");

expect(openAttachment).toHaveBeenCalledWith(attachment);
expect(wrapper.get(".chat-attachment").attributes("role")).toBe("button");
});

it("removes without opening when the remove button is clicked", async () => {
const wrapper = mount(ChatAttachments, {
props: { attachments: [attachment], removable: true },
});

await wrapper.get(".chat-attachment__remove").trigger("click");

expect(openAttachment).not.toHaveBeenCalled();
expect(wrapper.emitted("remove")).toEqual([[0]]);
});

it("does not open when keyboard events originate from the remove button", async () => {
const wrapper = mount(ChatAttachments, {
props: { attachments: [attachment], removable: true },
});
const removeButton = wrapper.get(".chat-attachment__remove");

await removeButton.trigger("keydown", { key: "Enter" });
await removeButton.trigger("click");

expect(openAttachment).not.toHaveBeenCalled();
expect(wrapper.emitted("remove")).toEqual([[0]]);
});

it("does not show clickable affordance for an unavailable history attachment", () => {
const wrapper = mount(ChatAttachments, {
props: {
attachments: [{ ...attachment, content: "", mediaPath: undefined }],
},
});

expect(wrapper.get(".chat-attachment").attributes("role")).toBeUndefined();
expect(wrapper.get(".chat-attachment").classes()).not.toContain("chat-attachment--openable");
});

it("opens a history attachment with an absolute inbound media path", async () => {
const historyAttachment = {
...attachment,
content: "",
mediaPath: "C:\\Users\\sunt\\.openclaw\\media\\inbound\\notes.txt",
};
const wrapper = mount(ChatAttachments, {
props: { attachments: [historyAttachment] },
});

await wrapper.get(".chat-attachment").trigger("click");

expect(openAttachment).toHaveBeenCalledWith(historyAttachment);
});
});
177 changes: 177 additions & 0 deletions desktop/renderer/src/components/chat/ChatAttachments.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<template>
<div v-if="attachments.length" class="chat-attachments">
<div
v-for="(attachment, index) in attachments"
:key="`${attachment.fileName}-${attachment.size}-${index}`"
class="chat-attachment"
:class="{ 'chat-attachment--openable': isOpenable(attachment) }"
:role="isOpenable(attachment) ? 'button' : undefined"
:tabindex="isOpenable(attachment) ? 0 : undefined"
@click.stop="openAttachment(attachment)"
@keydown.enter.self.stop.prevent="openAttachment(attachment)"
@keydown.space.self.stop.prevent="openAttachment(attachment)"
>
<img
v-if="attachment.type === 'image' && attachment.content"
class="chat-attachment__thumbnail"
:src="imageSource(attachment)"
alt=""
/>
<span v-else class="chat-attachment__icon" aria-hidden="true">&#x1F4CE;</span>
<span class="chat-attachment__details">
<span class="chat-attachment__name">
{{
attachment.fileName || t(attachment.type === "image" ? "chat.image" : "chat.attachment")
}}
</span>
<span v-if="attachment.size" class="chat-attachment__size">
{{ formatFileSize(attachment.size) }}
</span>
</span>
<button
v-if="removable"
class="chat-attachment__remove"
type="button"
:title="t('chat.removeAttachment', { file: attachment.fileName })"
:aria-label="t('chat.removeAttachment', { file: attachment.fileName })"
@click.stop="$emit('remove', index)"
@keydown.enter.stop
@keydown.space.stop
>
&times;
</button>
</div>
</div>
</template>

<script setup lang="ts">
import { t } from "@/i18n";
import { ElMessage } from "element-plus";

defineProps<{
attachments: ChatAttachment[];
removable?: boolean;
}>();

defineEmits<{
remove: [index: number];
}>();

function imageSource(attachment: ChatAttachment): string {
return attachment.content.startsWith("data:")
? attachment.content
: `data:${attachment.mimeType};base64,${attachment.content}`;
}

function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function isOpenable(attachment: ChatAttachment): boolean {
return (
attachment.content.length > 0 ||
(typeof attachment.mediaPath === "string" &&
(/^media:\/\/inbound\/[^/?#]+$/i.test(attachment.mediaPath) ||
/^[a-z]:\\.*\\media\\inbound\\[^\\]+$/i.test(attachment.mediaPath)))
);
}

async function openAttachment(attachment: ChatAttachment) {
if (!isOpenable(attachment)) return;
try {
const result = await window.openclaw.attachment.open(attachment);
if (!result.ok) {
ElMessage.error(t("chat.attachment.openFailed", { error: result.error || "" }));
}
} catch (error) {
ElMessage.error(t("chat.attachment.openFailed", { error: String(error) }));
}
}
</script>

<style scoped>
.chat-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.chat-attachment {
display: flex;
min-width: 0;
max-width: 240px;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--ux-border);
border-radius: 10px;
background: var(--ux-surface-secondary);
}

.chat-attachment--openable {
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}

.chat-attachment--openable:hover,
.chat-attachment--openable:focus-visible {
border-color: var(--ux-focus);
background: var(--ux-surface-hover);
outline: none;
}

.chat-attachment__thumbnail {
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 6px;
object-fit: cover;
}

.chat-attachment__icon {
flex: 0 0 auto;
font-size: 18px;
}

.chat-attachment__details {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}

.chat-attachment__name {
overflow: hidden;
color: var(--ux-text-primary);
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}

.chat-attachment__size {
color: var(--ux-text-muted);
font-size: 10px;
}

.chat-attachment__remove {
width: 22px;
height: 22px;
flex: 0 0 auto;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ux-text-secondary);
cursor: pointer;
font-size: 18px;
line-height: 1;
}

.chat-attachment__remove:hover {
background: var(--ux-surface-hover);
}
</style>
20 changes: 16 additions & 4 deletions desktop/renderer/src/components/chat/ChatMessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,16 @@
</button>
</div>
</div>
<div v-else class="chat-text chat-text--user">
{{ getMessageText(msg) }}
</div>
<template v-else>
<ChatAttachments
v-if="chatStore.getMessageAttachments(msg).length"
class="chat-message-attachments"
:attachments="chatStore.getMessageAttachments(msg)"
/>
<div v-if="getMessageText(msg)" class="chat-text chat-text--user">
{{ getMessageText(msg) }}
</div>
</template>
</template>
</div>
</div>
Expand Down Expand Up @@ -482,6 +489,7 @@ import {
stripPlanMarkers,
} from "@/composables/usePlanProgress";
import PlanProgressPanel from "./PlanProgress.vue";
import ChatAttachments from "./ChatAttachments.vue";
import searchGif from "@/assets/openclaw_search_preview_transparent.gif";
import bookGif from "@/assets/book.gif";
import binocularsGif from "@/assets/binoculars.gif";
Expand Down Expand Up @@ -911,7 +919,7 @@ function cancelEdit() {

async function confirmEdit() {
const text = editText.value.trim();
if (!text || !chatStore.wsConnected) return;
if (!text || !chatStore.wsConnected || chatStore.sending || chatStore.streaming) return;
cancelEdit();
await chatStore.sendMessage(text);
}
Expand Down Expand Up @@ -982,6 +990,10 @@ function handleEditKeydown(e: KeyboardEvent) {
cursor: default;
}

.chat-bubble.user .chat-message-attachments {
margin-bottom: 8px;
}

.chat-bubble.user.editable {
cursor: pointer;
}
Expand Down
Loading