feat: align attachment uploads and bump 0.4.0

This commit is contained in:
Benjamin Shafii
2026-01-25 17:24:12 -08:00
parent dc9e5e7bfb
commit 142f83668d
12 changed files with 278 additions and 184 deletions

View File

@@ -3,7 +3,12 @@
"mcp": {
"chrome-devtools": {
"type": "local",
"command": ["npx", "-y", "chrome-devtools-mcp@latest"]
}
"command": [
"npx",
"-y",
"chrome-devtools-mcp@latest"
]
}
},
"model": "openai/gpt-5.2-codex"
}

View File

@@ -1,7 +1,7 @@
{
"name": "@different-ai/openwork-ui",
"private": true,
"version": "0.3.7",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -328,15 +328,11 @@ export default function App() {
}
for (const attachment of draft.attachments) {
if (attachment.kind === "image") {
parts.push({ type: "image", image: attachment.dataUrl, mediaType: attachment.mimeType } as Part);
continue;
}
parts.push({
type: "file",
data: attachment.dataUrl,
url: attachment.dataUrl,
filename: attachment.name,
mediaType: attachment.mimeType,
mime: attachment.mimeType,
} as Part);
}
@@ -2665,6 +2661,31 @@ export default function App() {
setLanguage: setLocale,
});
const searchWorkspaceFiles = async (query: string) => {
const trimmed = query.trim();
if (!trimmed) return [];
if (isDemoMode()) {
const lower = trimmed.toLowerCase();
return activeWorkingFiles().filter((file) => file.toLowerCase().includes(lower));
}
const activeClient = client();
if (!activeClient) return [];
try {
const directory = workspaceProjectDir().trim();
const result = unwrap(
await activeClient.find.files({
query: trimmed,
dirs: "true",
limit: 50,
directory: directory || undefined,
}),
);
return result;
} catch {
return [];
}
};
const sessionProps = () => ({
selectedSessionId: activeSessionId(),
setView,
@@ -2730,6 +2751,7 @@ export default function App() {
openCommandRunModal: openRunModal,
commandRegistryItems,
registerCommand: commandRegistry.registerCommand,
searchFiles: searchWorkspaceFiles,
onTryNotionPrompt: () => {
setPrompt("setup my crm");
setTryNotionPromptVisible(false);

View File

@@ -1,6 +1,6 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type { Agent } from "@opencode-ai/sdk/v2/client";
import { ArrowRight, AtSign, File, Paperclip, Terminal, X, Zap } from "lucide-solid";
import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid";
import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types";
@@ -33,6 +33,15 @@ type ComposerProps = {
onInsertCommand: (commandId: string) => void;
selectedModelLabel: string;
onModelClick: () => void;
agentLabel: string;
selectedAgent: string | null;
agentPickerOpen: boolean;
agentPickerBusy: boolean;
agentPickerError: string | null;
agentOptions: Agent[];
onToggleAgentPicker: () => void;
onSelectAgent: (agent: string | null) => void;
setAgentPickerRef: (el: HTMLDivElement) => void;
showNotionBanner: boolean;
onNotionBannerClick: () => void;
toast: string | null;
@@ -319,7 +328,9 @@ export default function Composer(props: ComposerProps) {
const syncHeight = () => {
if (!editorRef) return;
editorRef.style.height = "auto";
const nextHeight = Math.min(editorRef.scrollHeight, 160);
const baseHeight = 24;
const scrollHeight = editorRef.scrollHeight || baseHeight;
const nextHeight = Math.min(Math.max(scrollHeight, baseHeight), 160);
editorRef.style.height = `${nextHeight}px`;
editorRef.style.overflowY = editorRef.scrollHeight > 160 ? "auto" : "hidden";
};
@@ -697,8 +708,6 @@ export default function Composer(props: ComposerProps) {
onCleanup(() => window.removeEventListener("openwork:focusPrompt", handler));
});
const modeLabel = createMemo(() => (mode() === "shell" ? "Shell" : "Prompt"));
return (
<div class="p-4 border-t border-gray-6 bg-gray-1 sticky bottom-0 z-20">
<div class="max-w-2xl mx-auto">
@@ -880,23 +889,11 @@ export default function Composer(props: ComposerProps) {
</div>
</Show>
<div class="flex items-end gap-3">
<div class="flex flex-col gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<div
class={`flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide border ${
mode() === "shell"
? "border-amber-7/30 bg-amber-7/10 text-amber-12"
: "border-gray-6 bg-gray-1/70 text-gray-9"
}`}
>
<Terminal size={11} />
<span>{modeLabel()}</span>
</div>
<Show when={props.isRemoteWorkspace}>
<div class="text-[10px] uppercase tracking-wider text-gray-8">Remote workspace</div>
<div class="mb-2 text-[10px] uppercase tracking-wider text-gray-8">Remote workspace</div>
</Show>
</div>
<div class="relative">
<Show when={!props.prompt.trim() && !attachments().length}>
@@ -917,12 +914,88 @@ export default function Composer(props: ComposerProps) {
onKeyUp={updateMentionQuery}
onClick={updateMentionQuery}
onPaste={handlePaste}
class="bg-transparent border-none p-0 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
/>
<div class="absolute bottom-0 left-0 z-20" ref={props.setAgentPickerRef}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
onClick={props.onToggleAgentPicker}
aria-expanded={props.agentPickerOpen}
>
<div class="p-1 rounded bg-gray-4 text-gray-10">
<AtSign size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[10rem]">
{props.agentLabel}
</span>
<span class="text-[10px] text-gray-10 font-mono leading-none">
{props.selectedAgent ? "Agent" : "Default"}
</span>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
</button>
<Show when={props.agentPickerOpen}>
<div class="absolute left-0 bottom-full mb-2 w-72 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
Session agent
</div>
<div class="max-h-64 overflow-auto p-2 space-y-1">
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedAgent ? "text-gray-11 hover:bg-gray-12/5" : "bg-gray-12/10 text-gray-12"
}`}
onClick={() => props.onSelectAgent(null)}
>
<span>Default agent</span>
<Show when={!props.selectedAgent}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
<Show
when={!props.agentPickerBusy}
fallback={<div class="px-3 py-2 text-xs text-gray-9">Loading agents...</div>}
>
<Show
when={props.agentOptions.length}
fallback={<div class="px-3 py-2 text-xs text-gray-9">No agents available.</div>}
>
<For each={props.agentOptions}>
{(agent: Agent) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedAgent === agent.name
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
}`}
onClick={() => props.onSelectAgent(agent.name)}
>
<span>{agent.name}</span>
<Show when={props.selectedAgent === agent.name}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
)}
</For>
</Show>
</Show>
<Show when={props.agentPickerError}>
<div class="px-3 py-2 text-xs text-red-11">{props.agentPickerError}</div>
</Show>
</div>
<div class="border-t border-gray-6/40 px-4 py-2 text-[10px] text-gray-9">
Tip: use /agent-next or /agent-prev to cycle.
</div>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<div class="absolute bottom-0 right-0 z-20 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
@@ -972,5 +1045,7 @@ export default function Composer(props: ComposerProps) {
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -39,6 +39,24 @@ type MessageBlockItem = MessageBlock | StepClusterBlock;
export default function MessageList(props: MessageListProps) {
const [copyingId, setCopyingId] = createSignal<string | null>(null);
let copyTimeout: number | undefined;
const isAttachmentPart = (part: Part) => {
if (part.type !== "file") return false;
const url = (part as { url?: string }).url;
return typeof url === "string" && !url.startsWith("file://");
};
const attachmentsForMessage = (message: MessageWithParts) =>
message.parts
.filter(isAttachmentPart)
.map((part) => {
const record = part as { url?: string; filename?: string; mime?: string };
return {
url: record.url ?? "",
filename: record.filename ?? "attachment",
mime: record.mime ?? "application/octet-stream",
};
})
.filter((attachment) => !!attachment.url);
const isImageAttachment = (mime: string) => mime.startsWith("image/");
onCleanup(() => {
if (copyTimeout !== undefined) {
@@ -269,6 +287,32 @@ export default function MessageList(props: MessageListProps) {
: "max-w-[68ch] text-[15px] leading-7 text-gray-12 group pl-2"
}`}
>
<Show when={attachmentsForMessage(block.message).length > 0}>
<div class={block.isUser ? "mb-3 flex flex-wrap gap-2" : "mb-4 flex flex-wrap gap-2"}>
<For each={attachmentsForMessage(block.message)}>
{(attachment) => (
<div class="flex items-center gap-2 rounded-2xl border border-gray-6 bg-gray-1/70 px-3 py-2 text-xs text-gray-11">
<Show
when={isImageAttachment(attachment.mime)}
fallback={<File size={14} class="text-gray-9" />}
>
<div class="h-12 w-12 rounded-xl bg-gray-2 overflow-hidden border border-gray-6">
<img
src={attachment.url}
alt={attachment.filename}
class="h-full w-full object-cover"
/>
</div>
</Show>
<div class="max-w-[180px]">
<div class="truncate text-gray-12">{attachment.filename}</div>
<div class="text-[10px] text-gray-9">{attachment.mime}</div>
</div>
</div>
)}
</For>
</div>
</Show>
<For each={block.groups}>
{(group, idx) => (
<div class={idx() === block.groups.length - 1 ? "" : groupSpacing}>

View File

@@ -127,8 +127,9 @@ export function createSessionStore(options: {
const reloadDetectionSet = new Set<string>();
const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i;
const opencodeConfigPattern = /(?:^|[\\/])opencode\.json\b/i;
const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i;
const opencodePathPattern = /(?:^|[\\/])\.opencode[\\/]/i;
const mutatingTools = new Set(["write", "edit", "apply_patch"]);
const extractSearchText = (value: unknown) => {
if (!value) return "";
@@ -146,15 +147,37 @@ export function createSessionStore(options: {
return null;
};
const detectReloadReasonDeep = (value: unknown): ReloadReason | null => {
if (!value) return null;
if (typeof value === "string" || typeof value === "number") {
return detectReloadReason(value);
}
if (Array.isArray(value)) {
for (const entry of value) {
const reason = detectReloadReasonDeep(entry);
if (reason) return reason;
}
return null;
}
if (typeof value === "object") {
for (const entry of Object.values(value as Record<string, unknown>)) {
const reason = detectReloadReasonDeep(entry);
if (reason) return reason;
}
}
return null;
};
const detectReloadFromPart = (part: Part): ReloadReason | null => {
if (part.type !== "tool") return null;
const record = part as Record<string, unknown>;
const toolName = typeof record.tool === "string" ? record.tool : "";
if (!mutatingTools.has(toolName)) return null;
const state = (record.state ?? {}) as Record<string, unknown>;
return (
detectReloadReason(record.text) ||
detectReloadReason(record.path) ||
detectReloadReason(record.title) ||
detectReloadReason((record.state as { title?: unknown })?.title) ||
detectReloadReason((record.state as { output?: unknown })?.output) ||
detectReloadReason((record.state as { input?: unknown })?.input)
detectReloadReasonDeep(state.input) ||
detectReloadReasonDeep(state.patch) ||
detectReloadReasonDeep(state.diff)
);
};

View File

@@ -17,7 +17,6 @@ import type {
import {
AlertTriangle,
AtSign,
ArrowRight,
ChevronDown,
HardDrive,
@@ -94,6 +93,7 @@ export type SessionViewProps = {
providers: Provider[];
providerConnectedIds: string[];
listAgents: () => Promise<Agent[]>;
searchFiles: (query: string) => Promise<string[]>;
selectedSessionAgent: string | null;
setSessionAgent: (sessionId: string, agent: string | null) => void;
saveSession: (sessionId: string) => Promise<string>;
@@ -553,7 +553,6 @@ export default function SessionView(props: SessionViewProps) {
const sessionId = requireSessionId();
if (!sessionId) return;
props.setSessionAgent(sessionId, agent);
setCommandToast(agent ? `Agent set to ${agent}` : "Agent cleared");
};
const cycleAgent = async (direction: "next" | "prev") => {
@@ -582,7 +581,6 @@ export default function SessionView(props: SessionViewProps) {
return;
}
props.setSessionAgent(sessionId, nextAgent);
setCommandToast(`Agent set to ${nextAgent}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Agent selection failed";
setCommandToast(message);
@@ -741,7 +739,6 @@ export default function SessionView(props: SessionViewProps) {
}
props.setSessionAgent(sessionId, match.name);
setCommandToast(`Agent set to ${match.name}`);
clearPrompt();
} catch (error) {
const message = error instanceof Error ? error.message : "Agent selection failed";
@@ -917,12 +914,6 @@ export default function SessionView(props: SessionViewProps) {
props.setPrompt(draft.text);
};
const searchFiles = async (query: string) => {
const q = query.trim().toLowerCase();
if (!q) return [];
return props.workingFiles.filter((file) => file.toLowerCase().includes(q));
};
return (
<Show
when={props.selectedSessionId}
@@ -970,91 +961,6 @@ export default function SessionView(props: SessionViewProps) {
</Show>
</div>
<div class="relative flex items-center gap-2" ref={(el) => (agentPickerRef = el)}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-2 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-4 transition-all group"
onClick={openAgentPicker}
aria-expanded={agentPickerOpen()}
>
<div class="p-1 rounded bg-gray-4 text-gray-10">
<AtSign size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[10rem]">
{agentLabel()}
</span>
<span class="text-[10px] text-gray-10 font-mono leading-none">
{props.selectedSessionAgent ? "Agent" : "Default"}
</span>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
</button>
<Show when={agentPickerOpen()}>
<div class="absolute right-0 top-full mt-2 w-72 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
Session agent
</div>
<div class="max-h-64 overflow-auto p-2 space-y-1">
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedSessionAgent
? "text-gray-11 hover:bg-gray-12/5"
: "bg-gray-12/10 text-gray-12"
}`}
onClick={() => {
applySessionAgent(null);
setAgentPickerOpen(false);
}}
>
<span>Default agent</span>
<Show when={!props.selectedSessionAgent}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
<Show
when={!agentPickerBusy()}
fallback={<div class="px-3 py-2 text-xs text-gray-9">Loading agents...</div>}
>
<Show
when={agentOptions().length}
fallback={<div class="px-3 py-2 text-xs text-gray-9">No agents available.</div>}
>
<For each={agentOptions()}>
{(agent: Agent) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedSessionAgent === agent.name
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
}`}
onClick={() => {
applySessionAgent(agent.name);
setAgentPickerOpen(false);
}}
>
<span>{agent.name}</span>
<Show when={props.selectedSessionAgent === agent.name}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
)}
</For>
</Show>
</Show>
<Show when={agentPickerError()}>
<div class="px-3 py-2 text-xs text-red-11">{agentPickerError()}</div>
</Show>
</div>
<div class="border-t border-gray-6/40 px-4 py-2 text-[10px] text-gray-9">
Tip: use /agent-next or /agent-prev to cycle.
</div>
</div>
</Show>
</div>
</header>
<Show when={props.error}>
@@ -1195,13 +1101,27 @@ export default function SessionView(props: SessionViewProps) {
onInsertCommand={handleInsertCommand}
selectedModelLabel={props.selectedSessionModelLabel || "Model"}
onModelClick={props.openSessionModelPicker}
agentLabel={agentLabel()}
selectedAgent={props.selectedSessionAgent}
agentPickerOpen={agentPickerOpen()}
agentPickerBusy={agentPickerBusy()}
agentPickerError={agentPickerError()}
agentOptions={agentOptions()}
onToggleAgentPicker={openAgentPicker}
onSelectAgent={(agent) => {
applySessionAgent(agent);
setAgentPickerOpen(false);
}}
setAgentPickerRef={(el) => {
agentPickerRef = el;
}}
showNotionBanner={props.showTryNotionPrompt}
onNotionBannerClick={props.onTryNotionPrompt}
toast={commandToast()}
onToast={(message) => setCommandToast(message)}
listAgents={props.listAgents}
recentFiles={props.workingFiles}
searchFiles={searchFiles}
searchFiles={props.searchFiles}
isRemoteWorkspace={props.activeWorkspaceDisplay.workspaceType === "remote"}
/>

View File

@@ -438,7 +438,12 @@ export function groupMessageParts(parts: Part[], messageId: string): MessageGrou
}
if (part.type === "file") {
const record = part as { label?: string; path?: string; filename?: string };
const record = part as { label?: string; path?: string; filename?: string; url?: string };
const url = record.url;
if (typeof url === "string" && !url.startsWith("file://")) {
flushText();
return;
}
const label = record.label ?? record.path ?? record.filename ?? "";
textBuffer += label ? `@${label}` : "@file";
return;

View File

@@ -1,7 +1,7 @@
{
"name": "@different-ai/openwork",
"private": true,
"version": "0.3.7",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "tauri dev --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",

View File

@@ -2381,7 +2381,7 @@ dependencies = [
[[package]]
name = "openwork"
version = "0.3.7"
version = "0.4.0"
dependencies = [
"json5",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "openwork"
version = "0.3.7"
version = "0.4.0"
description = "OpenWork"
authors = ["Different AI"]
edition = "2021"

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenWork",
"version": "0.3.7",
"version": "0.4.0",
"identifier": "com.differentai.openwork",
"build": {
"beforeDevCommand": "pnpm -C ../.. --filter @different-ai/openwork run prepare:sidecar && pnpm -w dev:ui",