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
2 changes: 2 additions & 0 deletions docs/en/wework/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Include the goal, scope, constraints, and acceptance criteria. For complex work,

Add files, images, code locations, or an Appshot to the composer when they clarify the task. Type `/` to select an available Skill.

After you send a local image, Wework keeps its preview in the message. The image remains available when you reopen Wework or return to the conversation after switching away. If the original local file is deleted, the preview cannot be restored.

## Models and devices

The model provides the AI capability; the device determines where files and commands run. Local models run on the local device. Cloud models and devices require a Wegent connection.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/wework/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ sidebar_position: 4

你可以在输入区添加文件、图片、代码位置和应用快照。应用快照会截取当前前台窗口,并在获得系统辅助功能权限时附带窗口文本。

发送本地图片后,Wework 会在消息中保留图片预览。关闭并重新打开 Wework 或切换到其他会话后,返回原会话仍可查看该图片;如果原始本地文件已被删除,预览将无法恢复。

输入 `/` 可以选择可用 Skill。插件安装后,其 Skills、命令或工具会出现在对应任务能力中。

## 选择模型和设备
Expand Down
42 changes: 41 additions & 1 deletion wework/e2e/desktop/task-flow.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,12 @@ async function attachAndSendOnlyFile(control, composerSelector) {
stableMs: COMPOSER_READY_STABILITY_MS,
timeoutMs: UI_TIMEOUT_MS,
})
await control.command(
'waitFor',
`${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-image-preview"]`,
{ timeoutMs: UI_TIMEOUT_MS }
)
await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
}

async function verifyAttachmentOnlySidebarLifecycle({ appIdentifier, composerSelector, control }) {
Expand All @@ -1185,6 +1191,8 @@ async function verifyAttachmentOnlySidebarLifecycle({ appIdentifier, composerSel
const firstRows = firstSnapshot.testIds.filter(testId =>
testId.startsWith('runtime-local-task-row-')
)
const firstTaskRowTestId = firstRows.at(-1)
assert.ok(firstTaskRowTestId, 'The first attachment-only task did not expose a task row')
await captureVerificationScreenshot(control, '02-attachment-only-first-completed.png')

await control.command('click', '[data-testid="new-chat-button"]')
Expand All @@ -1208,6 +1216,8 @@ async function verifyAttachmentOnlySidebarLifecycle({ appIdentifier, composerSel
const expectedRows = twoTaskSnapshot.testIds.filter(testId =>
testId.startsWith('runtime-local-task-row-')
)
const secondTaskRowTestId = expectedRows.find(testId => !firstRows.includes(testId))
assert.ok(secondTaskRowTestId, 'The second attachment-only task did not expose a task row')
await captureVerificationScreenshot(control, '04-attachment-only-two-tasks-after-refresh.png')

if (process.platform === 'darwin') {
Expand All @@ -1229,7 +1239,37 @@ async function verifyAttachmentOnlySidebarLifecycle({ appIdentifier, composerSel
timeoutMs: WORKBENCH_READY_TIMEOUT_MS,
})
}
await captureVerificationScreenshot(control, '05-attachment-only-two-tasks-after-reopen.png')
await control.command('clickWhenEnabled', `[data-testid="${secondTaskRowTestId}"]`, {
stableMs: COMPOSER_READY_STABILITY_MS,
timeoutMs: WORKBENCH_READY_TIMEOUT_MS,
})
await control.command('waitFor', '[data-testid="message-assistant"]', {
text: `${ATTACHMENT_ONLY_COMPLETION_TEXT}_2`,
timeoutMs: UI_TIMEOUT_MS,
})
await control.command(
'waitFor',
`${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-image-preview"]`,
{ timeoutMs: UI_TIMEOUT_MS }
)
await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
await captureVerificationScreenshot(control, '05-attachment-only-current-image-after-reopen.png')

await control.command('clickWhenEnabled', `[data-testid="${firstTaskRowTestId}"]`, {
stableMs: COMPOSER_READY_STABILITY_MS,
timeoutMs: WORKBENCH_READY_TIMEOUT_MS,
})
await control.command('waitFor', '[data-testid="message-assistant"]', {
text: `${ATTACHMENT_ONLY_COMPLETION_TEXT}_1`,
timeoutMs: UI_TIMEOUT_MS,
})
await control.command(
'waitFor',
`${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-image-preview"]`,
{ timeoutMs: UI_TIMEOUT_MS }
)
await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
await captureVerificationScreenshot(control, '06-attachment-only-first-image-after-reopen.png')

const requests = control.scenarioRequests.get('attachment_only') ?? []
assert.equal(requests.length, 2, 'Attachment-only flow did not send exactly two model requests')
Expand Down
28 changes: 14 additions & 14 deletions wework/src/components/chat/AttachmentImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,38 +52,37 @@ function clampIndex(value: number, length: number): number {
async function loadAttachmentImageUrl(
attachment: Attachment
): Promise<{ url: string; objectUrl: string | null }> {
if (attachment.local_preview_url) {
const cachedLocalPreviewUrl = resolvedLocalAttachmentPreviewUrls.get(
attachment.local_preview_url
)
const localPreviewUrl = attachment.local_preview_url ?? attachment.local_path
if (localPreviewUrl) {
const cachedLocalPreviewUrl = resolvedLocalAttachmentPreviewUrls.get(localPreviewUrl)
if (cachedLocalPreviewUrl) {
return { url: cachedLocalPreviewUrl, objectUrl: null }
}

if (failedAttachmentPreviewUrls.has(attachment.local_preview_url)) {
if (failedAttachmentPreviewUrls.has(localPreviewUrl)) {
throw new Error('Local attachment preview already failed')
}

const localPath = getDownloadableLocalPath(attachment.local_preview_url)
const localPath = getDownloadableLocalPath(localPreviewUrl)
if (localPath && isTauriRuntime()) {
try {
const exists = await invoke<boolean>('local_path_exists', { path: localPath })
if (!exists) {
failedAttachmentPreviewUrls.add(attachment.local_preview_url)
failedAttachmentPreviewUrls.add(localPreviewUrl)
throw new Error('Local attachment preview no longer exists')
}
} catch (error) {
if (failedAttachmentPreviewUrls.has(attachment.local_preview_url)) {
if (failedAttachmentPreviewUrls.has(localPreviewUrl)) {
throw error
}
}
}

const resolvedLocalPreviewUrl = resolveDirectMarkdownImageSrc(attachment.local_preview_url)
const resolvedLocalPreviewUrl = resolveDirectMarkdownImageSrc(localPreviewUrl)
if (!resolvedLocalPreviewUrl) {
throw new Error('Failed to resolve local attachment preview')
}
resolvedLocalAttachmentPreviewUrls.set(attachment.local_preview_url, resolvedLocalPreviewUrl)
resolvedLocalAttachmentPreviewUrls.set(localPreviewUrl, resolvedLocalPreviewUrl)
return { url: resolvedLocalPreviewUrl, objectUrl: null }
}

Expand All @@ -106,8 +105,9 @@ async function loadAttachmentImageUrl(
}

function rememberFailedAttachmentPreview(attachment: Attachment) {
if (attachment.local_preview_url) {
failedAttachmentPreviewUrls.add(attachment.local_preview_url)
const localPreviewUrl = attachment.local_preview_url ?? attachment.local_path
if (localPreviewUrl) {
failedAttachmentPreviewUrls.add(localPreviewUrl)
}
}

Expand Down Expand Up @@ -150,7 +150,7 @@ function getDownloadableLocalPath(value?: string): string | null {
}

async function downloadAttachmentImage(attachment: Attachment, imageUrl: string) {
const sourcePath = getDownloadableLocalPath(attachment.local_preview_url)
const sourcePath = getDownloadableLocalPath(attachment.local_preview_url ?? attachment.local_path)
if (sourcePath && isTauriRuntime()) {
await invoke<string>('download_local_file_to_downloads', {
sourcePath,
Expand Down Expand Up @@ -186,7 +186,7 @@ export function AttachmentImagePreview({
const [zoom, setZoom] = useState(1)
const [shouldLoadPreview, setShouldLoadPreview] = useState(false)
const previewContainerRef = useRef<HTMLElement | null>(null)
const previewIdentity = `${attachment.id}:${attachment.local_preview_url ?? ''}`
const previewIdentity = `${attachment.id}:${attachment.local_preview_url ?? attachment.local_path ?? ''}`
const setPreviewContainerRef = useCallback((element: HTMLElement | null) => {
previewContainerRef.current = element
}, [])
Expand Down
36 changes: 36 additions & 0 deletions wework/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2925,6 +2925,42 @@ describe('MessageList', () => {
expect(fetch).not.toHaveBeenCalled()
})

test('restores historical image previews from persisted local paths', async () => {
vi.stubGlobal('fetch', vi.fn())

const attachment: Attachment = {
id: -1,
filename: 'historical.png',
file_size: 1024,
mime_type: 'image/png',
status: 'ready',
file_extension: '.png',
created_at: '2026-06-26T15:33:00.000+08:00',
local_path: '/Users/me/.wegent-executor/workspace/attachments/draft/42/historical.png',
}

render(
<MessageList
messages={[
{
id: 'historical-image',
role: 'user',
content: '查看历史图片',
status: 'done',
attachments: [attachment],
createdAt: '2026-06-26T15:33:00.000+08:00',
},
]}
/>
)

expect(await screen.findByTestId('message-image-preview')).toHaveAttribute(
'src',
'asset://localhost/Users/me/.wegent-executor/workspace/attachments/draft/42/historical.png'
)
expect(fetch).not.toHaveBeenCalled()
})

test('downloads local path image attachments through the Tauri native command', async () => {
vi.stubGlobal('fetch', vi.fn())
tauriCoreMock.isTauri = vi.fn(() => true)
Expand Down
5 changes: 3 additions & 2 deletions wework/src/lib/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Attachment } from '@/types/api'
import { persistAttachmentReferences } from './attachments'

describe('attachment helpers', () => {
test('removes transient object URLs from persisted message attachments', () => {
test('replaces transient object URLs with durable local paths', () => {
const attachment: Attachment = {
id: 7,
filename: 'image.png',
Expand All @@ -13,10 +13,11 @@ describe('attachment helpers', () => {
file_extension: '.png',
created_at: '2026-05-27T00:00:00.000Z',
local_preview_url: 'blob:composer-preview',
local_path: '/tmp/image.png',
}

expect(persistAttachmentReferences([attachment])).toEqual([
{ ...attachment, local_preview_url: undefined },
{ ...attachment, local_preview_url: '/tmp/image.png' },
])
expect(attachment.local_preview_url).toBe('blob:composer-preview')
})
Expand Down
13 changes: 8 additions & 5 deletions wework/src/lib/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ export function releaseAttachmentPreview(attachment: Attachment): void {
}

export function persistAttachmentReferences(attachments: Attachment[]): Attachment[] {
return attachments.map(attachment =>
isObjectUrl(attachment.local_preview_url)
? { ...attachment, local_preview_url: undefined }
: attachment
)
return attachments.map(attachment => {
if (!isObjectUrl(attachment.local_preview_url)) return attachment

return {
...attachment,
local_preview_url: attachment.local_path,
}
})
}

export function isImageAttachment(attachment: Attachment): boolean {
Expand Down
Loading