mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
bbe997fb86
commit
307c8e2b2a
@@ -31,6 +31,7 @@
|
||||
"updater:default",
|
||||
"allow-core-process",
|
||||
"allow-workspace-files",
|
||||
"allow-artifact-download",
|
||||
"allow-app-update",
|
||||
"allow-loopback-oauth"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[[permission]]
|
||||
identifier = "allow-artifact-download"
|
||||
description = "Allow copying agent-generated artifacts from the workspace artifacts/ tree to the user's Downloads folder (#2779)"
|
||||
|
||||
[permission.commands]
|
||||
|
||||
allow = [
|
||||
"download_artifact_to_downloads",
|
||||
]
|
||||
|
||||
deny = []
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Tauri command for downloading agent-generated artifacts (#2779).
|
||||
//!
|
||||
//! Contract: the frontend resolves the artifact's absolute source
|
||||
//! path via the existing `openhuman.ai_get_artifact` core RPC, then
|
||||
//! invokes [`download_artifact_to_downloads`] with that source path
|
||||
//! plus a filename hint. The command:
|
||||
//!
|
||||
//! 1. Validates both inputs (no path traversal in the filename, source
|
||||
//! must be absolute + on disk).
|
||||
//! 2. Resolves the user's Downloads directory via the `dirs` crate.
|
||||
//! 3. Picks a non-colliding destination filename — `name.pptx`,
|
||||
//! `name (1).pptx`, `name (2).pptx`, …
|
||||
//! 4. Copies source → dest with `tokio::fs::copy`.
|
||||
//! 5. Returns the absolute dest path so the frontend can show a
|
||||
//! "Saved to …" toast with a "Reveal in Finder" button (the
|
||||
//! `opener:allow-reveal-item-in-dir` capability is already wired).
|
||||
//!
|
||||
//! Why Downloads instead of a native save-file dialog: the
|
||||
//! `tauri-plugin-dialog` crate pulls `tauri-plugin-fs` transitively,
|
||||
//! which currently breaks the openhuman build with a `schemars`
|
||||
//! version conflict. The Downloads + reveal pattern satisfies the
|
||||
//! "user-chosen destination" intent of issue #2779 AC#3 without
|
||||
//! widening the Tauri allow-list, and matches what most desktop chat
|
||||
//! apps do for downloaded attachments.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Maximum number of `(N)` suffixes we'll append when picking a
|
||||
/// non-colliding filename. After 1000 we give up and append a UUID
|
||||
/// suffix instead so the download never silently overwrites.
|
||||
const MAX_COLLISION_SUFFIX: u32 = 1000;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_artifact_to_downloads(
|
||||
source_path: String,
|
||||
filename: String,
|
||||
) -> Result<String, String> {
|
||||
if source_path.trim().is_empty() {
|
||||
return Err("source_path must not be empty".to_string());
|
||||
}
|
||||
if filename.trim().is_empty() {
|
||||
return Err("filename must not be empty".to_string());
|
||||
}
|
||||
let source = PathBuf::from(&source_path);
|
||||
if !source.is_absolute() {
|
||||
return Err(format!(
|
||||
"source_path must be absolute (came from ai_get_artifact): {source_path:?}"
|
||||
));
|
||||
}
|
||||
if !source.is_file() {
|
||||
return Err(format!(
|
||||
"artifact source not present on disk: {source_path}"
|
||||
));
|
||||
}
|
||||
let sanitized = sanitize_filename(&filename)?;
|
||||
|
||||
let downloads = directories::UserDirs::new()
|
||||
.and_then(|u| u.download_dir().map(|p| p.to_path_buf()))
|
||||
.ok_or_else(|| "OS Downloads directory not resolvable".to_string())?;
|
||||
tokio::fs::create_dir_all(&downloads)
|
||||
.await
|
||||
.map_err(|e| format!("failed to ensure Downloads dir {:?}: {e}", downloads))?;
|
||||
|
||||
let dest = pick_unique_path(&downloads, &sanitized);
|
||||
let bytes = tokio::fs::copy(&source, &dest)
|
||||
.await
|
||||
.map_err(|e| format!("failed to copy artifact to {:?}: {e}", dest))?;
|
||||
|
||||
log::info!(
|
||||
"[artifact_commands] download_artifact_to_downloads bytes={bytes} dest={}",
|
||||
dest.display()
|
||||
);
|
||||
Ok(dest.display().to_string())
|
||||
}
|
||||
|
||||
/// Strip path-traversal characters from a filename hint. The
|
||||
/// renderer is expected to pass something like `"My Deck.pptx"`;
|
||||
/// reject anything that contains a separator or null byte so a
|
||||
/// malicious `ai_get_artifact` response can never escape Downloads.
|
||||
fn sanitize_filename(name: &str) -> Result<String, String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("filename must not be empty after trim".to_string());
|
||||
}
|
||||
if trimmed.contains('/') || trimmed.contains('\\') {
|
||||
return Err(format!(
|
||||
"filename must not contain path separators: {trimmed:?}"
|
||||
));
|
||||
}
|
||||
if trimmed.contains('\0') {
|
||||
return Err(format!("filename must not contain NUL bytes: {trimmed:?}"));
|
||||
}
|
||||
if trimmed == "." || trimmed == ".." {
|
||||
return Err(format!("filename must not be '.' or '..': {trimmed:?}"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Pick a destination path under `dir` that does not exist yet.
|
||||
/// Inserts ` (N)` between the stem and the extension. Falls back to
|
||||
/// a UUID suffix after [`MAX_COLLISION_SUFFIX`] tries.
|
||||
fn pick_unique_path(dir: &Path, filename: &str) -> PathBuf {
|
||||
let candidate = dir.join(filename);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
let (stem, ext) = split_stem_ext(filename);
|
||||
for n in 1..=MAX_COLLISION_SUFFIX {
|
||||
let nth = if ext.is_empty() {
|
||||
format!("{stem} ({n})")
|
||||
} else {
|
||||
format!("{stem} ({n}).{ext}")
|
||||
};
|
||||
let path = dir.join(&nth);
|
||||
if !path.exists() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
// 1000 collisions is implausible in practice; if we hit it, fall
|
||||
// back to a monotonic nanosecond suffix so the copy still succeeds
|
||||
// without overwriting anything. Reaches for the OS clock instead of
|
||||
// pulling in `uuid` as a Tauri-shell dep just for this corner.
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let with_uniq = if ext.is_empty() {
|
||||
format!("{stem}-{nanos}")
|
||||
} else {
|
||||
format!("{stem}-{nanos}.{ext}")
|
||||
};
|
||||
dir.join(with_uniq)
|
||||
}
|
||||
|
||||
fn split_stem_ext(filename: &str) -> (String, String) {
|
||||
if let Some(idx) = filename.rfind('.') {
|
||||
// Reject leading-dot files (`.hidden`) — treat as having no extension.
|
||||
if idx > 0 && idx < filename.len() - 1 {
|
||||
return (filename[..idx].to_string(), filename[idx + 1..].to_string());
|
||||
}
|
||||
}
|
||||
(filename.to_string(), String::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_path_separators() {
|
||||
assert!(sanitize_filename("../etc/passwd").is_err());
|
||||
assert!(sanitize_filename("a\\b.pptx").is_err());
|
||||
assert!(sanitize_filename("a/b.pptx").is_err());
|
||||
assert!(sanitize_filename("").is_err());
|
||||
assert!(sanitize_filename(".").is_err());
|
||||
assert!(sanitize_filename("..").is_err());
|
||||
assert!(sanitize_filename("ok.pptx\0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_accepts_plain_names() {
|
||||
assert_eq!(
|
||||
sanitize_filename("Quarterly Update.pptx").unwrap(),
|
||||
"Quarterly Update.pptx"
|
||||
);
|
||||
assert_eq!(sanitize_filename(" trim me ").unwrap(), "trim me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_stem_ext_pairs() {
|
||||
assert_eq!(
|
||||
split_stem_ext("file.pptx"),
|
||||
("file".to_string(), "pptx".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
split_stem_ext("noext"),
|
||||
("noext".to_string(), String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
split_stem_ext(".hidden"),
|
||||
(".hidden".to_string(), String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
split_stem_ext("trailing."),
|
||||
("trailing.".to_string(), String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
split_stem_ext("a.b.c"),
|
||||
("a.b".to_string(), "c".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_unique_inserts_collision_suffix() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let first = pick_unique_path(dir, "deck.pptx");
|
||||
assert_eq!(first, dir.join("deck.pptx"));
|
||||
|
||||
std::fs::write(&first, b"").unwrap();
|
||||
let second = pick_unique_path(dir, "deck.pptx");
|
||||
assert_eq!(second, dir.join("deck (1).pptx"));
|
||||
|
||||
std::fs::write(&second, b"").unwrap();
|
||||
let third = pick_unique_path(dir, "deck.pptx");
|
||||
assert_eq!(third, dir.join("deck (2).pptx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_unique_handles_no_extension() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let first = pick_unique_path(dir, "noext");
|
||||
assert_eq!(first, dir.join("noext"));
|
||||
std::fs::write(&first, b"").unwrap();
|
||||
let second = pick_unique_path(dir, "noext");
|
||||
assert_eq!(second, dir.join("noext (1)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_rejects_invalid_inputs() {
|
||||
assert!(
|
||||
download_artifact_to_downloads(String::new(), "x.pptx".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
download_artifact_to_downloads("/tmp/x".to_string(), String::new())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
download_artifact_to_downloads("relative".to_string(), "x.pptx".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
download_artifact_to_downloads("/nope".to_string(), "../escape.pptx".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile.");
|
||||
|
||||
mod cdp;
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
mod artifact_commands;
|
||||
mod cdp;
|
||||
mod cef_preflight;
|
||||
mod cef_profile;
|
||||
mod companion_commands;
|
||||
@@ -3081,6 +3082,12 @@ pub fn run() {
|
||||
core_rpc_token,
|
||||
overlay_parent_rpc_url,
|
||||
process_diagnostics_list_owned,
|
||||
// `mod artifact_commands;` is `#[cfg(any(target_os = "macos", target_os = "linux"))]`
|
||||
// (Downloads-dir + `tokio::fs::copy` flow is non-Windows-only today).
|
||||
// The handler entry MUST carry the same gate or Windows builds fail
|
||||
// with "function not found in scope" (CR #3328947313 on PR #3026).
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
artifact_commands::download_artifact_to_downloads,
|
||||
check_core_update,
|
||||
apply_core_update,
|
||||
check_app_update,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice';
|
||||
import ArtifactCard from './ArtifactCard';
|
||||
|
||||
// Mock the artifact download service — the card only consumes the
|
||||
// two public functions, so a per-test override is enough.
|
||||
const downloadArtifactMock = vi.fn();
|
||||
const revealArtifactInFileManagerMock = vi.fn();
|
||||
vi.mock('../../services/artifactDownloadService', () => ({
|
||||
downloadArtifact: (...args: unknown[]) => downloadArtifactMock(...args),
|
||||
revealArtifactInFileManager: (...args: unknown[]) => revealArtifactInFileManagerMock(...args),
|
||||
}));
|
||||
|
||||
function snapshot(overrides: Partial<ArtifactSnapshot> = {}): ArtifactSnapshot {
|
||||
return {
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Quarterly Deck',
|
||||
status: 'ready',
|
||||
sizeBytes: 4096,
|
||||
path: 'a-1/deck.pptx',
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
downloadArtifactMock.mockReset();
|
||||
revealArtifactInFileManagerMock.mockReset();
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — in_progress state', () => {
|
||||
it('renders the title with the generating sub-label and no buttons', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({ status: 'in_progress', sizeBytes: undefined, path: undefined })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Quarterly Deck')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generating presentation/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the artifact kind in the generating label', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'in_progress',
|
||||
kind: 'image',
|
||||
sizeBytes: undefined,
|
||||
path: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Generating image/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — ready state', () => {
|
||||
it('renders the Download button with the formatted size', () => {
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
|
||||
// formatFileSize on 4096 should land in the "4 KB"-ish range; we
|
||||
// just assert the size cell exists rather than asserting an exact
|
||||
// formatter output to avoid coupling to its implementation detail.
|
||||
const subLabel = screen.getByText(/Ready ·/);
|
||||
expect(subLabel.textContent ?? '').toMatch(/[KMG]?B/);
|
||||
});
|
||||
|
||||
it('omits the size suffix when sizeBytes is null', () => {
|
||||
render(<ArtifactCard artifact={snapshot({ sizeBytes: undefined })} />);
|
||||
|
||||
// Status text should be empty when sizeBytes is missing on a ready
|
||||
// artifact — the conditional collapses to ''.
|
||||
const dl = screen.getByRole('button', { name: 'Download' });
|
||||
expect(dl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drives the download → done flow and reveals on click', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
path: '/Users/me/Downloads/Quarterly Deck.pptx',
|
||||
});
|
||||
revealArtifactInFileManagerMock.mockResolvedValueOnce(true);
|
||||
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
|
||||
// Service called with (id, title, ext) where ext comes from the
|
||||
// title's extension when present, or kind fallback otherwise.
|
||||
await waitFor(() => expect(downloadArtifactMock).toHaveBeenCalledTimes(1));
|
||||
expect(downloadArtifactMock).toHaveBeenCalledWith('a-1', 'Quarterly Deck', 'pptx');
|
||||
|
||||
// Saved-to row appears with the reveal button.
|
||||
await screen.findByText(
|
||||
(_, el) => el?.textContent === 'Saved to /Users/me/Downloads/Quarterly Deck.pptx'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show in folder' }));
|
||||
await waitFor(() => expect(revealArtifactInFileManagerMock).toHaveBeenCalledTimes(1));
|
||||
expect(revealArtifactInFileManagerMock).toHaveBeenCalledWith(
|
||||
'/Users/me/Downloads/Quarterly Deck.pptx'
|
||||
);
|
||||
|
||||
// Download button is gone now that state === 'done'.
|
||||
expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the kind-based extension when title has no dot', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Doc.pdf' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-2', kind: 'document', title: 'Doc' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(downloadArtifactMock).toHaveBeenCalledTimes(1));
|
||||
expect(downloadArtifactMock).toHaveBeenCalledWith('a-2', 'Doc', 'pdf');
|
||||
});
|
||||
|
||||
it('falls back to png for image kind without an extension', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Pic.png' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-3', kind: 'image', title: 'Pic' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(downloadArtifactMock).toHaveBeenCalledTimes(1));
|
||||
expect(downloadArtifactMock).toHaveBeenCalledWith('a-3', 'Pic', 'png');
|
||||
});
|
||||
|
||||
it('falls back to bin for the "other" kind without an extension', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Blob.bin' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-4', kind: 'other', title: 'Blob' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(downloadArtifactMock).toHaveBeenCalledTimes(1));
|
||||
expect(downloadArtifactMock).toHaveBeenCalledWith('a-4', 'Blob', 'bin');
|
||||
});
|
||||
|
||||
it('uses the existing extension on the title when present', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({ ok: true, path: '/tmp/notes.txt' });
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({ artifactId: 'a-5', kind: 'document', title: 'notes.txt' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(downloadArtifactMock).toHaveBeenCalledTimes(1));
|
||||
expect(downloadArtifactMock).toHaveBeenCalledWith('a-5', 'notes.txt', 'txt');
|
||||
});
|
||||
|
||||
it('surfaces the service error message when download fails', async () => {
|
||||
downloadArtifactMock.mockResolvedValueOnce({ ok: false, error: 'disk full' });
|
||||
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
|
||||
await screen.findByText((_, el) => el?.textContent === 'Download failed: disk full');
|
||||
// Download button stays so the user can retry.
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not call reveal when download.path is missing', async () => {
|
||||
// Synthetic edge: the success path always carries a path, but if the
|
||||
// user clicks reveal before a download ever happened the handler is
|
||||
// guarded.
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
// No reveal button is rendered until state === 'done'.
|
||||
expect(screen.queryByRole('button', { name: 'Show in folder' })).not.toBeInTheDocument();
|
||||
expect(revealArtifactInFileManagerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — failed state', () => {
|
||||
it('shows the producer error verbatim under the preview cap', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'producer crashed',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('producer crashed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates long errors with a Show more toggle', () => {
|
||||
const huge = 'x'.repeat(400);
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: huge,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// Truncated body ends with the ellipsis suffix.
|
||||
const truncated = screen.getByText(/x…$/);
|
||||
expect(truncated).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more' }));
|
||||
// After expand, the full string is rendered without the ellipsis.
|
||||
expect(screen.getByText(huge)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show less' }));
|
||||
// Re-collapses back to truncated form.
|
||||
expect(screen.getByText(/x…$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Retry button when onRetry is provided', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'nope',
|
||||
})}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
|
||||
const retry = screen.getByRole('button', { name: 'Retry' });
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledWith('a-1');
|
||||
});
|
||||
|
||||
it('omits the Retry button when onRetry is not provided', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'nope',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the error block entirely when no error string is set', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — aria label', () => {
|
||||
it('exposes an aria-label with the artifact title', () => {
|
||||
render(<ArtifactCard artifact={snapshot({ title: 'Q3 Plan' })} />);
|
||||
expect(screen.getByRole('group', { name: /Q3 Plan/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { formatFileSize } from '../../lib/attachments';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
downloadArtifact,
|
||||
revealArtifactInFileManager,
|
||||
} from '../../services/artifactDownloadService';
|
||||
import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice';
|
||||
|
||||
/**
|
||||
* Inline chat card surfacing a single agent-generated artifact (#2779).
|
||||
*
|
||||
* Renders three visual states keyed off `artifact.status`:
|
||||
*
|
||||
* - `in_progress` — pulsing dot + title + "Generating <kind>…" label.
|
||||
* Derived state: a `ChatToolCallEvent` for an artifact-producing
|
||||
* tool was seen but no `artifact_ready` / `artifact_failed` has
|
||||
* landed yet.
|
||||
* - `ready` — kind icon + title + human-readable size + Download
|
||||
* button. Click → `downloadArtifact()` → "Saved to …" w/ a
|
||||
* "Show in folder" link.
|
||||
* - `failed` — error icon + title + producer-supplied reason +
|
||||
* optional Retry button (only when `onRetry` is provided).
|
||||
*
|
||||
* Visual style mirrors `ApprovalRequestCard` / `AttachmentPreview`:
|
||||
* rounded card, dark/light Tailwind variants, mono accents on
|
||||
* numeric values, inline SVG icons. No new icon dependency.
|
||||
*/
|
||||
export interface ArtifactCardProps {
|
||||
artifact: ArtifactSnapshot;
|
||||
/** When provided, render a Retry button on the `failed` state. */
|
||||
onRetry?: (artifactId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension hint for the Tauri download command. Falls back to a
|
||||
* lowercased kind slug when the title doesn't carry an explicit
|
||||
* extension (defensive — `create_artifact` sanitises the title +
|
||||
* extension separately, but a malformed title shouldn't crash the
|
||||
* card).
|
||||
*/
|
||||
function extensionFor(kind: ArtifactSnapshot['kind'], title: string): string {
|
||||
const dot = title.lastIndexOf('.');
|
||||
if (dot > 0 && dot < title.length - 1) {
|
||||
return title.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
switch (kind) {
|
||||
case 'presentation':
|
||||
return 'pptx';
|
||||
case 'document':
|
||||
return 'pdf';
|
||||
case 'image':
|
||||
return 'png';
|
||||
default:
|
||||
return 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
function KindIcon({ kind }: { kind: ArtifactSnapshot['kind'] }) {
|
||||
const stroke = 'currentColor';
|
||||
switch (kind) {
|
||||
case 'presentation':
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h18v12H3z" />
|
||||
<path strokeLinecap="round" d="M8 21h8M12 17v4" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11l3 3 4-5 3 4" />
|
||||
</svg>
|
||||
);
|
||||
case 'document':
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"
|
||||
/>
|
||||
<path strokeLinecap="round" d="M14 3v5h5M9 13h6M9 17h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'image':
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 5h18v14H3z" />
|
||||
<circle cx="9" cy="10" r="1.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 17l5-5 4 4 3-3 6 6" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4h16v16H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v3a5 5 0 00-5 5H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 flex-shrink-0 text-coral-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path strokeLinecap="round" d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the visible failure reason at ~280 chars. Producer-side errors
|
||||
* can be enormous (e.g. a multi-KB pip stderr from a failed venv
|
||||
* setup — observed at 13K chars in dev:app on 2026-05-30) and
|
||||
* dumping that raw into a flex card breaks layout + can freeze the
|
||||
* scrolling page. We collapse by default and let the user expand
|
||||
* via "Show more" if they actually want to read it.
|
||||
*/
|
||||
const ERROR_REASON_PREVIEW_CHARS = 280;
|
||||
|
||||
export default function ArtifactCard({ artifact, onRetry }: ArtifactCardProps) {
|
||||
const { t } = useT();
|
||||
const [download, setDownload] = useState<{
|
||||
state: 'idle' | 'downloading' | 'done' | 'error';
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>({ state: 'idle' });
|
||||
const [errorExpanded, setErrorExpanded] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownload({ state: 'downloading' });
|
||||
const ext = extensionFor(artifact.kind, artifact.title);
|
||||
const outcome = await downloadArtifact(artifact.artifactId, artifact.title, ext);
|
||||
if (outcome.ok) {
|
||||
setDownload({ state: 'done', path: outcome.path });
|
||||
} else {
|
||||
setDownload({ state: 'error', error: outcome.error });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReveal = async () => {
|
||||
if (download.path) {
|
||||
await revealArtifactInFileManager(download.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('chat.artifact.aria').replace('{title}', artifact.title)}
|
||||
className="flex flex-col gap-1.5 rounded-xl border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 px-3 py-2.5 text-sm text-stone-700 dark:text-neutral-200 max-w-[420px]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{artifact.status === 'in_progress' ? (
|
||||
<Spinner />
|
||||
) : artifact.status === 'failed' ? (
|
||||
<FailedIcon />
|
||||
) : (
|
||||
<KindIcon kind={artifact.kind} />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate font-medium leading-tight">{artifact.title}</span>
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400 leading-tight font-mono">
|
||||
{artifact.status === 'in_progress'
|
||||
? t('chat.artifact.generating').replace('{kind}', artifact.kind)
|
||||
: artifact.status === 'ready' && artifact.sizeBytes != null
|
||||
? `${t('chat.artifact.ready')} · ${formatFileSize(artifact.sizeBytes)}`
|
||||
: artifact.status === 'failed'
|
||||
? t('chat.artifact.failed')
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{artifact.status === 'ready' && download.state !== 'done' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={download.state === 'downloading'}
|
||||
className="ml-auto rounded-md bg-ocean-500 hover:bg-ocean-600 disabled:bg-stone-300 dark:disabled:bg-neutral-700 text-white text-xs font-medium px-3 py-1.5 transition-colors">
|
||||
{download.state === 'downloading'
|
||||
? t('chat.artifact.downloading')
|
||||
: t('chat.artifact.download')}
|
||||
</button>
|
||||
)}
|
||||
{artifact.status === 'failed' && onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(artifact.artifactId)}
|
||||
className="ml-auto rounded-md bg-stone-200 dark:bg-neutral-700 hover:bg-stone-300 dark:hover:bg-neutral-600 text-stone-700 dark:text-neutral-200 text-xs font-medium px-3 py-1.5 transition-colors">
|
||||
{t('chat.artifact.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{artifact.status === 'failed' && artifact.error && (
|
||||
<div className="text-xs text-coral-600 dark:text-coral-400 mt-1">
|
||||
<p
|
||||
className={`font-mono break-words whitespace-pre-wrap ${
|
||||
errorExpanded ? 'max-h-48 overflow-y-auto' : ''
|
||||
}`}>
|
||||
{errorExpanded || artifact.error.length <= ERROR_REASON_PREVIEW_CHARS
|
||||
? artifact.error
|
||||
: `${artifact.error.slice(0, ERROR_REASON_PREVIEW_CHARS)}…`}
|
||||
</p>
|
||||
{artifact.error.length > ERROR_REASON_PREVIEW_CHARS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setErrorExpanded(prev => !prev)}
|
||||
className="mt-1 underline text-coral-700 dark:text-coral-300 hover:text-coral-900 dark:hover:text-coral-100">
|
||||
{errorExpanded ? t('chat.artifact.show_less') : t('chat.artifact.show_more')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{download.state === 'done' && download.path && (
|
||||
<div className="flex items-center gap-2 text-xs text-sage-700 dark:text-sage-300 mt-1">
|
||||
<span className="truncate font-mono">
|
||||
{t('chat.artifact.downloaded').replace('{path}', download.path)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReveal}
|
||||
className="ml-auto underline hover:text-sage-900 dark:hover:text-sage-100 transition-colors flex-shrink-0">
|
||||
{t('chat.artifact.reveal')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{download.state === 'error' && download.error && (
|
||||
<p className="text-xs text-coral-600 dark:text-coral-400 mt-1 break-words">
|
||||
{t('chat.artifact.download_failed').replace('{reason}', download.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4385,6 +4385,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'رفض التخزين المحلي',
|
||||
'pages.settings.account.security': 'الأمان',
|
||||
'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'الملف: {title}',
|
||||
'chat.artifact.generating': 'جارٍ إنشاء {kind}…',
|
||||
'chat.artifact.ready': 'جاهز',
|
||||
'chat.artifact.failed': 'فشل الإنشاء',
|
||||
'chat.artifact.download': 'تنزيل',
|
||||
'chat.artifact.downloading': 'جارٍ التنزيل…',
|
||||
'chat.artifact.downloaded': 'تم الحفظ في {path}',
|
||||
'chat.artifact.download_failed': 'فشل التنزيل: {reason}',
|
||||
'chat.artifact.retry': 'إعادة المحاولة',
|
||||
'chat.artifact.reveal': 'عرض في المجلد',
|
||||
'chat.artifact.show_more': 'عرض المزيد',
|
||||
'chat.artifact.show_less': 'عرض أقل',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'إرفاق ملف',
|
||||
|
||||
@@ -4463,6 +4463,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন',
|
||||
'pages.settings.account.security': 'নিরাপত্তা',
|
||||
'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'আর্টিফ্যাক্ট: {title}',
|
||||
'chat.artifact.generating': '{kind} তৈরি হচ্ছে…',
|
||||
'chat.artifact.ready': 'প্রস্তুত',
|
||||
'chat.artifact.failed': 'তৈরি ব্যর্থ হয়েছে',
|
||||
'chat.artifact.download': 'ডাউনলোড',
|
||||
'chat.artifact.downloading': 'ডাউনলোড হচ্ছে…',
|
||||
'chat.artifact.downloaded': '{path} এ সংরক্ষিত',
|
||||
'chat.artifact.download_failed': 'ডাউনলোড ব্যর্থ: {reason}',
|
||||
'chat.artifact.retry': 'পুনরায় চেষ্টা',
|
||||
'chat.artifact.reveal': 'ফোল্ডারে দেখান',
|
||||
'chat.artifact.show_more': 'আরও দেখুন',
|
||||
'chat.artifact.show_less': 'কম দেখুন',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'ফাইল সংযুক্ত করুন',
|
||||
|
||||
@@ -4580,6 +4580,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen',
|
||||
'pages.settings.account.security': 'Sicherheit',
|
||||
'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefakt: {title}',
|
||||
'chat.artifact.generating': 'Erstelle {kind}…',
|
||||
'chat.artifact.ready': 'Bereit',
|
||||
'chat.artifact.failed': 'Erstellung fehlgeschlagen',
|
||||
'chat.artifact.download': 'Herunterladen',
|
||||
'chat.artifact.downloading': 'Wird heruntergeladen…',
|
||||
'chat.artifact.downloaded': 'Gespeichert unter {path}',
|
||||
'chat.artifact.download_failed': 'Download fehlgeschlagen: {reason}',
|
||||
'chat.artifact.retry': 'Erneut versuchen',
|
||||
'chat.artifact.reveal': 'Im Ordner anzeigen',
|
||||
'chat.artifact.show_more': 'Mehr anzeigen',
|
||||
'chat.artifact.show_less': 'Weniger anzeigen',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Datei anhängen',
|
||||
|
||||
@@ -4788,6 +4788,19 @@ const en: TranslationMap = {
|
||||
'pages.settings.account.security': 'Security',
|
||||
'pages.settings.account.securityDesc': 'Secret storage mode and keychain status',
|
||||
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artifact: {title}',
|
||||
'chat.artifact.generating': 'Generating {kind}…',
|
||||
'chat.artifact.ready': 'Ready',
|
||||
'chat.artifact.failed': 'Generation failed',
|
||||
'chat.artifact.download': 'Download',
|
||||
'chat.artifact.downloading': 'Downloading…',
|
||||
'chat.artifact.downloaded': 'Saved to {path}',
|
||||
'chat.artifact.download_failed': 'Download failed: {reason}',
|
||||
'chat.artifact.retry': 'Retry',
|
||||
'chat.artifact.reveal': 'Show in folder',
|
||||
'chat.artifact.show_more': 'Show more',
|
||||
'chat.artifact.show_less': 'Show less',
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Attach file',
|
||||
'composer.modelSelector': 'Model',
|
||||
|
||||
@@ -4546,6 +4546,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Rechazar almacenamiento local',
|
||||
'pages.settings.account.security': 'Seguridad',
|
||||
'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefacto: {title}',
|
||||
'chat.artifact.generating': 'Generando {kind}…',
|
||||
'chat.artifact.ready': 'Listo',
|
||||
'chat.artifact.failed': 'Error al generar',
|
||||
'chat.artifact.download': 'Descargar',
|
||||
'chat.artifact.downloading': 'Descargando…',
|
||||
'chat.artifact.downloaded': 'Guardado en {path}',
|
||||
'chat.artifact.download_failed': 'Error al descargar: {reason}',
|
||||
'chat.artifact.retry': 'Reintentar',
|
||||
'chat.artifact.reveal': 'Mostrar en la carpeta',
|
||||
'chat.artifact.show_more': 'Ver más',
|
||||
'chat.artifact.show_less': 'Ver menos',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Adjuntar archivo',
|
||||
|
||||
@@ -4562,6 +4562,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Refuser le stockage local',
|
||||
'pages.settings.account.security': 'Sécurité',
|
||||
'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefact : {title}',
|
||||
'chat.artifact.generating': 'Génération de {kind}…',
|
||||
'chat.artifact.ready': 'Prêt',
|
||||
'chat.artifact.failed': 'Échec de la génération',
|
||||
'chat.artifact.download': 'Télécharger',
|
||||
'chat.artifact.downloading': 'Téléchargement…',
|
||||
'chat.artifact.downloaded': 'Enregistré dans {path}',
|
||||
'chat.artifact.download_failed': 'Échec du téléchargement : {reason}',
|
||||
'chat.artifact.retry': 'Réessayer',
|
||||
'chat.artifact.reveal': 'Afficher dans le dossier',
|
||||
'chat.artifact.show_more': 'Voir plus',
|
||||
'chat.artifact.show_less': 'Voir moins',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Joindre un fichier',
|
||||
|
||||
@@ -4470,6 +4470,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें',
|
||||
'pages.settings.account.security': 'सुरक्षा',
|
||||
'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'आर्टिफैक्ट: {title}',
|
||||
'chat.artifact.generating': '{kind} बना रहा है…',
|
||||
'chat.artifact.ready': 'तैयार',
|
||||
'chat.artifact.failed': 'निर्माण विफल',
|
||||
'chat.artifact.download': 'डाउनलोड',
|
||||
'chat.artifact.downloading': 'डाउनलोड हो रहा है…',
|
||||
'chat.artifact.downloaded': '{path} में सहेजा गया',
|
||||
'chat.artifact.download_failed': 'डाउनलोड विफल: {reason}',
|
||||
'chat.artifact.retry': 'पुनः प्रयास',
|
||||
'chat.artifact.reveal': 'फ़ोल्डर में दिखाएं',
|
||||
'chat.artifact.show_more': 'और दिखाएं',
|
||||
'chat.artifact.show_less': 'कम दिखाएं',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'फ़ाइल संलग्न करें',
|
||||
|
||||
@@ -4480,6 +4480,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal',
|
||||
'pages.settings.account.security': 'Keamanan',
|
||||
'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefak: {title}',
|
||||
'chat.artifact.generating': 'Membuat {kind}…',
|
||||
'chat.artifact.ready': 'Siap',
|
||||
'chat.artifact.failed': 'Gagal dibuat',
|
||||
'chat.artifact.download': 'Unduh',
|
||||
'chat.artifact.downloading': 'Mengunduh…',
|
||||
'chat.artifact.downloaded': 'Disimpan ke {path}',
|
||||
'chat.artifact.download_failed': 'Unduhan gagal: {reason}',
|
||||
'chat.artifact.retry': 'Coba lagi',
|
||||
'chat.artifact.reveal': 'Tampilkan di folder',
|
||||
'chat.artifact.show_more': 'Tampilkan selengkapnya',
|
||||
'chat.artifact.show_less': 'Tampilkan lebih sedikit',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Lampirkan file',
|
||||
|
||||
@@ -4538,6 +4538,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale',
|
||||
'pages.settings.account.security': 'Sicurezza',
|
||||
'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefatto: {title}',
|
||||
'chat.artifact.generating': 'Generazione {kind}…',
|
||||
'chat.artifact.ready': 'Pronto',
|
||||
'chat.artifact.failed': 'Generazione fallita',
|
||||
'chat.artifact.download': 'Scarica',
|
||||
'chat.artifact.downloading': 'Scaricamento…',
|
||||
'chat.artifact.downloaded': 'Salvato in {path}',
|
||||
'chat.artifact.download_failed': 'Download fallito: {reason}',
|
||||
'chat.artifact.retry': 'Riprova',
|
||||
'chat.artifact.reveal': 'Mostra nella cartella',
|
||||
'chat.artifact.show_more': 'Mostra altro',
|
||||
'chat.artifact.show_less': 'Mostra meno',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Allega file',
|
||||
|
||||
@@ -4427,6 +4427,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': '로컬 저장소 거부',
|
||||
'pages.settings.account.security': '보안',
|
||||
'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': '아티팩트: {title}',
|
||||
'chat.artifact.generating': '{kind} 생성 중…',
|
||||
'chat.artifact.ready': '준비됨',
|
||||
'chat.artifact.failed': '생성 실패',
|
||||
'chat.artifact.download': '다운로드',
|
||||
'chat.artifact.downloading': '다운로드 중…',
|
||||
'chat.artifact.downloaded': '{path}에 저장됨',
|
||||
'chat.artifact.download_failed': '다운로드 실패: {reason}',
|
||||
'chat.artifact.retry': '다시 시도',
|
||||
'chat.artifact.reveal': '폴더에서 보기',
|
||||
'chat.artifact.show_more': '더 보기',
|
||||
'chat.artifact.show_less': '간단히 보기',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': '파일 첨부',
|
||||
|
||||
@@ -4537,6 +4537,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania',
|
||||
'pages.settings.account.security': 'Bezpieczeństwo',
|
||||
'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefakt: {title}',
|
||||
'chat.artifact.generating': 'Tworzenie {kind}…',
|
||||
'chat.artifact.ready': 'Gotowe',
|
||||
'chat.artifact.failed': 'Tworzenie nie powiodło się',
|
||||
'chat.artifact.download': 'Pobierz',
|
||||
'chat.artifact.downloading': 'Pobieranie…',
|
||||
'chat.artifact.downloaded': 'Zapisano w {path}',
|
||||
'chat.artifact.download_failed': 'Pobieranie nie powiodło się: {reason}',
|
||||
'chat.artifact.retry': 'Spróbuj ponownie',
|
||||
'chat.artifact.reveal': 'Pokaż w folderze',
|
||||
'chat.artifact.show_more': 'Pokaż więcej',
|
||||
'chat.artifact.show_less': 'Pokaż mniej',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Dołącz plik',
|
||||
|
||||
@@ -4536,6 +4536,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Recusar armazenamento local',
|
||||
'pages.settings.account.security': 'Segurança',
|
||||
'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Artefato: {title}',
|
||||
'chat.artifact.generating': 'Gerando {kind}…',
|
||||
'chat.artifact.ready': 'Pronto',
|
||||
'chat.artifact.failed': 'Falha ao gerar',
|
||||
'chat.artifact.download': 'Baixar',
|
||||
'chat.artifact.downloading': 'Baixando…',
|
||||
'chat.artifact.downloaded': 'Salvo em {path}',
|
||||
'chat.artifact.download_failed': 'Falha no download: {reason}',
|
||||
'chat.artifact.retry': 'Tentar novamente',
|
||||
'chat.artifact.reveal': 'Mostrar na pasta',
|
||||
'chat.artifact.show_more': 'Mostrar mais',
|
||||
'chat.artifact.show_less': 'Mostrar menos',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Anexar arquivo',
|
||||
|
||||
@@ -4506,6 +4506,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': 'Отклонить локальное хранилище',
|
||||
'pages.settings.account.security': 'Безопасность',
|
||||
'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': 'Артефакт: {title}',
|
||||
'chat.artifact.generating': 'Создание {kind}…',
|
||||
'chat.artifact.ready': 'Готово',
|
||||
'chat.artifact.failed': 'Сбой генерации',
|
||||
'chat.artifact.download': 'Скачать',
|
||||
'chat.artifact.downloading': 'Скачивание…',
|
||||
'chat.artifact.downloaded': 'Сохранено в {path}',
|
||||
'chat.artifact.download_failed': 'Сбой скачивания: {reason}',
|
||||
'chat.artifact.retry': 'Повторить',
|
||||
'chat.artifact.reveal': 'Показать в папке',
|
||||
'chat.artifact.show_more': 'Показать больше',
|
||||
'chat.artifact.show_less': 'Свернуть',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': 'Прикрепить файл',
|
||||
|
||||
@@ -4248,6 +4248,19 @@ const messages: TranslationMap = {
|
||||
'keyring.settings.revokeConsent': '拒绝本地存储',
|
||||
'pages.settings.account.security': '安全',
|
||||
'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态',
|
||||
// Chat — agent-generated artifacts (#2779)
|
||||
'chat.artifact.aria': '工件:{title}',
|
||||
'chat.artifact.generating': '正在生成{kind}…',
|
||||
'chat.artifact.ready': '已就绪',
|
||||
'chat.artifact.failed': '生成失败',
|
||||
'chat.artifact.download': '下载',
|
||||
'chat.artifact.downloading': '下载中…',
|
||||
'chat.artifact.downloaded': '已保存到 {path}',
|
||||
'chat.artifact.download_failed': '下载失败:{reason}',
|
||||
'chat.artifact.retry': '重试',
|
||||
'chat.artifact.reveal': '在文件夹中显示',
|
||||
'chat.artifact.show_more': '显示更多',
|
||||
'chat.artifact.show_less': '收起',
|
||||
|
||||
// Chat composer toolbar
|
||||
'composer.attachFile': '附加文件',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
|
||||
import { checkPromptInjection, promptGuardMessage } from '../chat/promptInjectionGuard';
|
||||
import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
|
||||
import ArtifactCard from '../components/chat/ArtifactCard';
|
||||
import ChatComposer from '../components/chat/ChatComposer';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
@@ -231,6 +232,7 @@ const Conversations = ({
|
||||
const inferenceStatusByThread = useAppSelector(
|
||||
state => state.chatRuntime.inferenceStatusByThread
|
||||
);
|
||||
const artifactsByThread = useAppSelector(state => state.chatRuntime.artifactsByThread);
|
||||
const pendingApprovalByThread = useAppSelector(
|
||||
state => state.chatRuntime.pendingApprovalByThread
|
||||
);
|
||||
@@ -2093,6 +2095,43 @@ const Conversations = ({
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
// Surface artifact cards for the shown thread above the composer
|
||||
// (#2779). Mirrors the approval-card placement so the user sees
|
||||
// the just-generated deck without scrolling. Cards stay visible
|
||||
// across turns until the thread is cleared. ArtifactCard handles
|
||||
// its own download lifecycle (dialog → copy → "Saved to …").
|
||||
const artifactThreadId = selectedThreadId ?? activeThreadId;
|
||||
const artifacts = artifactThreadId ? (artifactsByThread[artifactThreadId] ?? []) : [];
|
||||
if (artifacts.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-2 flex flex-col gap-2">
|
||||
{artifacts.map(artifact => (
|
||||
// NOTE: two intentionally-deferred surface gaps live here,
|
||||
// both tracked in follow-up issue #3162:
|
||||
//
|
||||
// 1. `onRetry` is intentionally omitted — `ArtifactCard`
|
||||
// declares the prop as optional and renders a Retry
|
||||
// button only when it's wired. Real retry (either
|
||||
// `removeArtifact(thread, id)` to let the user
|
||||
// re-prompt, or full re-dispatch of the producing
|
||||
// tool call) is out of scope for #2779. The
|
||||
// failed-card UI still surfaces the truncated error
|
||||
// reason; the button just stays hidden until #3162.
|
||||
//
|
||||
// 2. The card's in-progress / "generating…" state is
|
||||
// unreachable from this call site today — we only
|
||||
// push an `ArtifactSnapshot` into `artifactsByThread`
|
||||
// on `ArtifactReady` / `ArtifactFailed`, not on the
|
||||
// earlier `ChatToolCallEvent` that fires when the
|
||||
// agent dispatches `generate_presentation`. Wiring
|
||||
// that event through is the other half of #3162.
|
||||
<ArtifactCard key={artifact.artifactId} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{composer === 'mic-cloud' ? (
|
||||
<div className="flex flex-col items-center gap-3 py-1">
|
||||
<MicComposer
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
type StreamingAssistantState,
|
||||
type ToolTimelineEntry,
|
||||
type ToolTimelineEntryStatus,
|
||||
upsertArtifactFailedForThread,
|
||||
upsertArtifactReadyForThread,
|
||||
} from '../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
@@ -792,6 +794,44 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
});
|
||||
},
|
||||
onArtifactReady: event => {
|
||||
rtLog('artifact_ready', {
|
||||
thread: event.thread_id,
|
||||
artifact_id: event.artifact_id,
|
||||
kind: event.kind,
|
||||
size_bytes: event.size_bytes,
|
||||
});
|
||||
dispatch(
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: event.thread_id,
|
||||
artifactId: event.artifact_id,
|
||||
kind: event.kind,
|
||||
title: event.title,
|
||||
path: event.path,
|
||||
sizeBytes: event.size_bytes,
|
||||
})
|
||||
);
|
||||
},
|
||||
onArtifactFailed: event => {
|
||||
// Defence-in-depth: producer is expected to pre-truncate the
|
||||
// reason, but cap again here so a leaky producer cannot dump
|
||||
// unbounded provider stderr into client telemetry.
|
||||
rtLog('artifact_failed', {
|
||||
thread: event.thread_id,
|
||||
artifact_id: event.artifact_id,
|
||||
kind: event.kind,
|
||||
error: event.error.slice(0, 80),
|
||||
});
|
||||
dispatch(
|
||||
upsertArtifactFailedForThread({
|
||||
threadId: event.thread_id,
|
||||
artifactId: event.artifact_id,
|
||||
kind: event.kind,
|
||||
title: event.title,
|
||||
error: event.error,
|
||||
})
|
||||
);
|
||||
},
|
||||
onApprovalRequest: (event: ChatApprovalRequestEvent) => {
|
||||
rtLog('approval_request', {
|
||||
thread: event.thread_id,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as chatService from '../../services/chatService';
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import { store } from '../../store';
|
||||
import { clearAllChatRuntime } from '../../store/chatRuntimeSlice';
|
||||
import { setStatusForUser } from '../../store/socketSlice';
|
||||
import { clearAllThreads } from '../../store/threadSlice';
|
||||
import ChatRuntimeProvider from '../ChatRuntimeProvider';
|
||||
|
||||
vi.mock('../../services/chatService', async () => {
|
||||
const actual = await vi.importActual<typeof chatService>('../../services/chatService');
|
||||
return { ...actual, subscribeChatEvents: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../services/api/threadApi', () => ({
|
||||
threadApi: {
|
||||
createNewThread: vi.fn(),
|
||||
getThreads: vi.fn(),
|
||||
getThreadMessages: vi.fn(),
|
||||
appendMessage: vi.fn(),
|
||||
generateTitleIfNeeded: vi.fn(),
|
||||
updateMessage: vi.fn(),
|
||||
deleteThread: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
getTaskBoard: vi.fn(),
|
||||
putTaskBoard: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/usageRefresh', () => ({ requestUsageRefresh: vi.fn() }));
|
||||
|
||||
const mockRefetchSnapshot = vi.fn();
|
||||
vi.mock('../../hooks/useRefetchSnapshotOnTurnEnd', () => ({
|
||||
useRefetchSnapshotOnTurnEnd: () => ({ refetch: mockRefetchSnapshot }),
|
||||
}));
|
||||
|
||||
function renderProvider(): chatService.ChatEventListeners {
|
||||
let captured: chatService.ChatEventListeners = {};
|
||||
vi.mocked(chatService.subscribeChatEvents).mockImplementation(listeners => {
|
||||
captured = listeners;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
store.dispatch(setStatusForUser({ userId: '__pending__', status: 'connected' }));
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ChatRuntimeProvider>
|
||||
<div />
|
||||
</ChatRuntimeProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
function resetRuntimeState() {
|
||||
store.dispatch(clearAllThreads());
|
||||
store.dispatch(clearAllChatRuntime());
|
||||
store.dispatch(setStatusForUser({ userId: '__pending__', status: 'disconnected' }));
|
||||
}
|
||||
|
||||
describe('ChatRuntimeProvider — artifact event dispatch (#2779)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRuntimeState();
|
||||
vi.mocked(threadApi.getThreads).mockResolvedValue({ threads: [], count: 0 });
|
||||
});
|
||||
|
||||
it('onArtifactReady upserts a ready snapshot keyed on the artifact id', () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onArtifactReady?.({
|
||||
thread_id: 'thread-1',
|
||||
artifact_id: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
size_bytes: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
const bucket = store.getState().chatRuntime.artifactsByThread['thread-1'];
|
||||
expect(bucket).toHaveLength(1);
|
||||
expect(bucket?.[0]).toMatchObject({
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
sizeBytes: 4096,
|
||||
status: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('onArtifactFailed records the producer-supplied error', () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onArtifactFailed?.({
|
||||
thread_id: 'thread-2',
|
||||
artifact_id: 'a-2',
|
||||
kind: 'document',
|
||||
title: 'Notes',
|
||||
error: 'producer crashed',
|
||||
});
|
||||
});
|
||||
|
||||
const bucket = store.getState().chatRuntime.artifactsByThread['thread-2'];
|
||||
expect(bucket).toHaveLength(1);
|
||||
expect(bucket?.[0]).toMatchObject({
|
||||
artifactId: 'a-2',
|
||||
kind: 'document',
|
||||
title: 'Notes',
|
||||
status: 'failed',
|
||||
error: 'producer crashed',
|
||||
});
|
||||
});
|
||||
|
||||
it('onArtifactFailed dispatches the FULL error to the store even when huge', () => {
|
||||
// The provider's rtLog truncates to 80 chars for telemetry, but the
|
||||
// dispatched payload must keep the full reason so the ArtifactCard
|
||||
// can offer "Show more".
|
||||
const listeners = renderProvider();
|
||||
const huge = 'p'.repeat(500);
|
||||
|
||||
act(() => {
|
||||
listeners.onArtifactFailed?.({
|
||||
thread_id: 'thread-3',
|
||||
artifact_id: 'a-3',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: huge,
|
||||
});
|
||||
});
|
||||
|
||||
const entry = store.getState().chatRuntime.artifactsByThread['thread-3']?.[0];
|
||||
expect(entry?.error).toHaveLength(500);
|
||||
expect(entry?.error).toBe(huge);
|
||||
});
|
||||
|
||||
it('an artifact_failed → artifact_ready sequence promotes the snapshot in place', () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onArtifactFailed?.({
|
||||
thread_id: 'thread-4',
|
||||
artifact_id: 'a-4',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: 'boom',
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
listeners.onArtifactReady?.({
|
||||
thread_id: 'thread-4',
|
||||
artifact_id: 'a-4',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-4/deck.pptx',
|
||||
size_bytes: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
const bucket = store.getState().chatRuntime.artifactsByThread['thread-4'];
|
||||
expect(bucket).toHaveLength(1);
|
||||
expect(bucket?.[0].status).toBe('ready');
|
||||
expect(bucket?.[0].error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps artifact buckets per-thread', () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onArtifactReady?.({
|
||||
thread_id: 'thread-a',
|
||||
artifact_id: 'a-1',
|
||||
kind: 'image',
|
||||
title: 'Pic',
|
||||
path: 'a-1/pic.png',
|
||||
size_bytes: 1,
|
||||
});
|
||||
listeners.onArtifactFailed?.({
|
||||
thread_id: 'thread-b',
|
||||
artifact_id: 'a-2',
|
||||
kind: 'document',
|
||||
title: 'Doc',
|
||||
error: 'denied',
|
||||
});
|
||||
});
|
||||
|
||||
const state = store.getState().chatRuntime.artifactsByThread;
|
||||
expect(state['thread-a']?.[0].status).toBe('ready');
|
||||
expect(state['thread-b']?.[0].status).toBe('failed');
|
||||
expect(state['thread-a']?.[0].artifactId).toBe('a-1');
|
||||
expect(state['thread-b']?.[0].artifactId).toBe('a-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Importing AFTER mocks are registered so the service binds to the stubs.
|
||||
import { downloadArtifact, revealArtifactInFileManager } from '../artifactDownloadService';
|
||||
|
||||
// Mock the Tauri common shim (safeInvoke + isTauri) so the service
|
||||
// runs entirely in JS — no real Tauri IPC reach.
|
||||
const invokeMock = vi.fn();
|
||||
const isTauriMock = vi.fn(() => true);
|
||||
vi.mock('../../utils/tauriCommands/common', () => ({
|
||||
safeInvoke: (...args: unknown[]) => invokeMock(...args),
|
||||
isTauri: () => isTauriMock(),
|
||||
}));
|
||||
|
||||
// Mock the core RPC client — the service only uses one method.
|
||||
const callCoreRpcMock = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => callCoreRpcMock(...args),
|
||||
}));
|
||||
|
||||
// Mock the opener plugin's reveal binding so we can assert it is
|
||||
// invoked with the canonical absolute-path argument.
|
||||
const revealMock = vi.fn();
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
revealItemInDir: (...args: unknown[]) => revealMock(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
callCoreRpcMock.mockReset();
|
||||
revealMock.mockReset();
|
||||
isTauriMock.mockReset();
|
||||
isTauriMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('downloadArtifact', () => {
|
||||
it('refuses outside Tauri with a user-facing message', async () => {
|
||||
isTauriMock.mockReturnValueOnce(false);
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'Downloads are only available in the desktop app' });
|
||||
expect(callCoreRpcMock).not.toHaveBeenCalled();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an empty artifact id', async () => {
|
||||
const out = await downloadArtifact(' ', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'artifact id missing' });
|
||||
expect(callCoreRpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a friendly error when ai_get_artifact rejects (Error)', async () => {
|
||||
callCoreRpcMock.mockRejectedValueOnce(new Error('rpc dropped'));
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.error).toBe('failed to resolve artifact: rpc dropped');
|
||||
});
|
||||
|
||||
it('stringifies non-Error rejections from the core RPC', async () => {
|
||||
callCoreRpcMock.mockRejectedValueOnce('boom');
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.error).toBe('failed to resolve artifact: boom');
|
||||
});
|
||||
|
||||
it('returns a clear error when the core response lacks absolute_path', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({ meta: { title: 'Deck' } });
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'artifact path missing from core response' });
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a null core response as missing path', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce(null);
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'artifact path missing from core response' });
|
||||
});
|
||||
|
||||
it('prefers the persisted title over the caller fallback', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
|
||||
meta: { title: 'Real Title' },
|
||||
});
|
||||
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Real Title.pptx');
|
||||
|
||||
const out = await downloadArtifact('a-1', 'Caller Fallback', 'pptx');
|
||||
|
||||
expect(out).toEqual({ ok: true, path: '/Users/me/Downloads/Real Title.pptx' });
|
||||
expect(callCoreRpcMock).toHaveBeenCalledWith({
|
||||
method: 'openhuman.ai_get_artifact',
|
||||
params: { artifact_id: 'a-1' },
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
|
||||
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
|
||||
filename: 'Real Title.pptx',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the caller-supplied title when meta.title is blank', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
|
||||
meta: { title: ' ' },
|
||||
});
|
||||
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx');
|
||||
|
||||
await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
|
||||
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
|
||||
filename: 'Deck.pptx',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the "artifact" placeholder when both title sources are empty', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/x.bin',
|
||||
meta: {},
|
||||
});
|
||||
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/artifact.bin');
|
||||
|
||||
await downloadArtifact('a-1', ' ', 'bin');
|
||||
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
|
||||
sourcePath: '/workspace/artifacts/a-1/x.bin',
|
||||
filename: 'artifact.bin',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips leading dots from the extension before appending', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
|
||||
meta: { title: 'Deck' },
|
||||
});
|
||||
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx');
|
||||
|
||||
await downloadArtifact('a-1', 'Deck', '..pptx');
|
||||
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
|
||||
sourcePath: '/workspace/artifacts/a-1/deck.pptx',
|
||||
filename: 'Deck.pptx',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the dot when extension is empty', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/raw',
|
||||
meta: { title: 'Raw' },
|
||||
});
|
||||
invokeMock.mockResolvedValueOnce('/Users/me/Downloads/Raw');
|
||||
|
||||
await downloadArtifact('a-1', 'Raw', ' ');
|
||||
expect(invokeMock).toHaveBeenCalledWith('download_artifact_to_downloads', {
|
||||
sourcePath: '/workspace/artifacts/a-1/raw',
|
||||
filename: 'Raw',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the Tauri error when the copy command rejects', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
|
||||
meta: { title: 'Deck' },
|
||||
});
|
||||
invokeMock.mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'disk full' });
|
||||
});
|
||||
|
||||
it('stringifies a non-Error Tauri rejection', async () => {
|
||||
callCoreRpcMock.mockResolvedValueOnce({
|
||||
absolute_path: '/workspace/artifacts/a-1/deck.pptx',
|
||||
meta: { title: 'Deck' },
|
||||
});
|
||||
invokeMock.mockRejectedValueOnce('nope');
|
||||
|
||||
const out = await downloadArtifact('a-1', 'Deck', 'pptx');
|
||||
expect(out).toEqual({ ok: false, error: 'nope' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('revealArtifactInFileManager', () => {
|
||||
it('no-ops outside Tauri', async () => {
|
||||
isTauriMock.mockReturnValueOnce(false);
|
||||
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
|
||||
expect(ok).toBe(false);
|
||||
expect(revealMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops on an empty absolute path', async () => {
|
||||
const ok = await revealArtifactInFileManager(' ');
|
||||
expect(ok).toBe(false);
|
||||
expect(revealMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes through the typed plugin binding', async () => {
|
||||
revealMock.mockResolvedValueOnce(undefined);
|
||||
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
|
||||
expect(ok).toBe(true);
|
||||
expect(revealMock).toHaveBeenCalledWith('/Users/me/Downloads/Deck.pptx');
|
||||
});
|
||||
|
||||
it('swallows reveal failures and returns false', async () => {
|
||||
revealMock.mockRejectedValueOnce(new Error('opener missing'));
|
||||
const ok = await revealArtifactInFileManager('/Users/me/Downloads/Deck.pptx');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { subscribeChatEvents } from '../chatService';
|
||||
import { socketService } from '../socketService';
|
||||
|
||||
vi.mock('../socketService', () => ({ socketService: { getSocket: vi.fn() } }));
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
function createMockSocket() {
|
||||
const handlers = new Map<string, Handler[]>();
|
||||
const on = vi.fn((event: string, cb: Handler) => {
|
||||
const existing = handlers.get(event) ?? [];
|
||||
existing.push(cb);
|
||||
handlers.set(event, existing);
|
||||
});
|
||||
const off = vi.fn((event: string, cb: Handler) => {
|
||||
const existing = handlers.get(event) ?? [];
|
||||
handlers.set(
|
||||
event,
|
||||
existing.filter(handler => handler !== cb)
|
||||
);
|
||||
});
|
||||
const emit = (event: string, payload: unknown) => {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
handler(payload);
|
||||
}
|
||||
};
|
||||
return { id: 'socket-1', on, off, emit };
|
||||
}
|
||||
|
||||
describe('chatService — artifact_ready / artifact_failed handlers (#2779)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('subscribes to artifact events under canonical snake_case names', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
|
||||
subscribeChatEvents({ onArtifactReady: () => {}, onArtifactFailed: () => {} });
|
||||
|
||||
const events = socket.on.mock.calls.map(call => call[0]);
|
||||
expect(events).toEqual(['artifact_ready', 'artifact_failed']);
|
||||
});
|
||||
|
||||
it('flattens the wire envelope into a typed ArtifactReadyEvent', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactReady = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactReady });
|
||||
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
client_id: 'web-x',
|
||||
args: {
|
||||
artifact_id: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
size_bytes: 4096,
|
||||
},
|
||||
});
|
||||
|
||||
expect(onArtifactReady).toHaveBeenCalledTimes(1);
|
||||
expect(onArtifactReady).toHaveBeenCalledWith({
|
||||
thread_id: 'thread-1',
|
||||
client_id: 'web-x',
|
||||
artifact_id: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
size_bytes: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an artifact_ready payload missing required fields', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactReady = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactReady });
|
||||
|
||||
// No args at all → skipped.
|
||||
socket.emit('artifact_ready', { thread_id: 'thread-1' });
|
||||
// Missing path → skipped.
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'document', title: 'Doc', size_bytes: 1 },
|
||||
});
|
||||
// Missing size_bytes → skipped.
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'document', title: 'Doc', path: 'a-1/doc.pdf' },
|
||||
});
|
||||
// Missing artifact_id → skipped.
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
args: { kind: 'document', title: 'Doc', path: 'a-1/doc.pdf', size_bytes: 1 },
|
||||
});
|
||||
|
||||
expect(onArtifactReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects artifact_ready with an unknown kind (not in the allowlist)', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactReady = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactReady });
|
||||
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
args: {
|
||||
artifact_id: 'a-1',
|
||||
// not in {presentation, document, image, other}
|
||||
kind: 'spreadsheet',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
size_bytes: 4096,
|
||||
},
|
||||
});
|
||||
|
||||
expect(onArtifactReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts every allowlisted kind for artifact_ready', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactReady = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactReady });
|
||||
|
||||
for (const kind of ['presentation', 'document', 'image', 'other'] as const) {
|
||||
socket.emit('artifact_ready', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: `a-${kind}`, kind, title: 'X', path: `a-${kind}/file`, size_bytes: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
expect(onArtifactReady).toHaveBeenCalledTimes(4);
|
||||
expect(onArtifactReady.mock.calls.map(c => (c[0] as { kind: string }).kind)).toEqual([
|
||||
'presentation',
|
||||
'document',
|
||||
'image',
|
||||
'other',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flattens artifact_failed into a typed ArtifactFailedEvent', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactFailed = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactFailed });
|
||||
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
client_id: 'web-y',
|
||||
args: {
|
||||
artifact_id: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: 'pip install failed',
|
||||
},
|
||||
});
|
||||
|
||||
expect(onArtifactFailed).toHaveBeenCalledTimes(1);
|
||||
expect(onArtifactFailed).toHaveBeenCalledWith({
|
||||
thread_id: 'thread-1',
|
||||
client_id: 'web-y',
|
||||
artifact_id: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: 'pip install failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an artifact_failed payload missing required fields', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactFailed = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactFailed });
|
||||
|
||||
// Missing error
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'presentation', title: 'Deck' },
|
||||
});
|
||||
// Missing artifact_id
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
args: { kind: 'presentation', title: 'Deck', error: 'x' },
|
||||
});
|
||||
// Missing title
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'presentation', error: 'x' },
|
||||
});
|
||||
|
||||
expect(onArtifactFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects artifact_failed with an unknown kind', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactFailed = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactFailed });
|
||||
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'video', title: 'X', error: 'boom' },
|
||||
});
|
||||
|
||||
expect(onArtifactFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the full error string on the dispatched event even when huge', () => {
|
||||
// The handler caps only the LOG line, not the dispatched payload.
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
const onArtifactFailed = vi.fn();
|
||||
|
||||
subscribeChatEvents({ onArtifactFailed });
|
||||
|
||||
const huge = 'x'.repeat(500);
|
||||
socket.emit('artifact_failed', {
|
||||
thread_id: 'thread-1',
|
||||
args: { artifact_id: 'a-1', kind: 'presentation', title: 'Deck', error: huge },
|
||||
});
|
||||
|
||||
expect(onArtifactFailed).toHaveBeenCalledTimes(1);
|
||||
const event = onArtifactFailed.mock.calls[0]![0] as { error: string };
|
||||
expect(event.error).toHaveLength(500);
|
||||
expect(event.error).toBe(huge);
|
||||
});
|
||||
|
||||
it('removes both artifact handlers on cleanup', () => {
|
||||
const socket = createMockSocket();
|
||||
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
|
||||
|
||||
const cleanup = subscribeChatEvents({ onArtifactReady: () => {}, onArtifactFailed: () => {} });
|
||||
cleanup();
|
||||
|
||||
const offEvents = socket.off.mock.calls.map(call => call[0]);
|
||||
expect(offEvents).toEqual(['artifact_ready', 'artifact_failed']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Artifact download service (#2779).
|
||||
*
|
||||
* Flow:
|
||||
*
|
||||
* 1. Call `openhuman.ai_get_artifact` via the existing core RPC client
|
||||
* to resolve the artifact's absolute on-disk path + meta.
|
||||
* 2. Invoke the Tauri `download_artifact_to_downloads` command with
|
||||
* the source path + a filename hint built from the artifact's
|
||||
* title. The command picks a non-colliding name under the user's
|
||||
* Downloads directory and copies the file.
|
||||
* 3. Return the resolved dest path so the UI can show a "Saved to …"
|
||||
* toast with a "Reveal in Finder" button (the `opener` plugin's
|
||||
* `reveal-item-in-dir` capability is already wired).
|
||||
*
|
||||
* No-ops outside Tauri (browser dev preview) — the download flow only
|
||||
* makes sense in the desktop shell.
|
||||
*/
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
|
||||
import { safeInvoke as invoke, isTauri } from '../utils/tauriCommands/common';
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
|
||||
/** Outcome surfaced to the UI for a single download attempt. */
|
||||
export interface DownloadArtifactOutcome {
|
||||
ok: boolean;
|
||||
/** Absolute destination path when `ok === true`. */
|
||||
path?: string;
|
||||
/** Short, user-facing error string when `ok === false`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the `data` field returned by the
|
||||
* `openhuman.ai_get_artifact` JSON-RPC method. We pull only the
|
||||
* fields we need; extra fields are tolerated.
|
||||
*/
|
||||
interface AiGetArtifactData {
|
||||
absolute_path?: string;
|
||||
/** Full ArtifactMeta nested under this key on the core RPC response. */
|
||||
meta?: { id?: string; title?: string; path?: string; kind?: string; status?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the source path + filename hint, then copy to Downloads.
|
||||
*
|
||||
* `extension` is the file extension WITHOUT the leading dot
|
||||
* (`"pptx"`, `"pdf"`, …). Used to build the Downloads filename when
|
||||
* the title doesn't already carry one.
|
||||
*/
|
||||
export async function downloadArtifact(
|
||||
artifactId: string,
|
||||
fallbackTitle: string,
|
||||
extension: string
|
||||
): Promise<DownloadArtifactOutcome> {
|
||||
if (!isTauri()) {
|
||||
return { ok: false, error: 'Downloads are only available in the desktop app' };
|
||||
}
|
||||
if (!artifactId.trim()) {
|
||||
return { ok: false, error: 'artifact id missing' };
|
||||
}
|
||||
|
||||
let resolved: AiGetArtifactData;
|
||||
try {
|
||||
const raw = await callCoreRpc<AiGetArtifactData>({
|
||||
method: 'openhuman.ai_get_artifact',
|
||||
params: { artifact_id: artifactId },
|
||||
});
|
||||
resolved = raw ?? {};
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: `failed to resolve artifact: ${reason}` };
|
||||
}
|
||||
|
||||
const sourcePath = resolved.absolute_path;
|
||||
if (!sourcePath) {
|
||||
return { ok: false, error: 'artifact path missing from core response' };
|
||||
}
|
||||
|
||||
// Prefer the persisted title (came from create_artifact's
|
||||
// sanitized stem) but fall back to the caller-supplied hint.
|
||||
const title = resolved.meta?.title?.trim() || fallbackTitle.trim() || 'artifact';
|
||||
const ext = extension.trim().replace(/^\.+/, '');
|
||||
const filename = ext ? `${title}.${ext}` : title;
|
||||
|
||||
try {
|
||||
const dest = await invoke<string>('download_artifact_to_downloads', { sourcePath, filename });
|
||||
return { ok: true, path: dest };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the user's file manager pointed at the just-downloaded file.
|
||||
* Uses the existing `opener:allow-reveal-item-in-dir` capability —
|
||||
* no new permission needed. Returns `false` when not in Tauri or the
|
||||
* invoke fails (caller usually ignores the result).
|
||||
*/
|
||||
export async function revealArtifactInFileManager(absolutePath: string): Promise<boolean> {
|
||||
if (!isTauri()) return false;
|
||||
if (!absolutePath.trim()) return false;
|
||||
try {
|
||||
// Use the plugin's typed binding — the raw `invoke('plugin:opener|
|
||||
// reveal_item_in_dir', { path })` shape silently no-ops because the
|
||||
// plugin expects `{ paths: [absolutePath] }` (array). The binding
|
||||
// handles the wrap.
|
||||
await revealItemInDir(absolutePath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Swallow — reveal is best-effort, the file is already saved.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[artifact] revealItemInDir failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,48 @@ export interface ChatApprovalRequestEvent {
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase variant of the Rust `ArtifactKind` enum surfaced on
|
||||
* artifact lifecycle socket events. Mirrors the slugs produced by
|
||||
* `ArtifactKind::as_str()` in `src/openhuman/artifacts/types.rs`.
|
||||
*/
|
||||
export type ArtifactKind = 'presentation' | 'document' | 'image' | 'other';
|
||||
|
||||
/**
|
||||
* Emitted when the core `artifacts::store::finalize_artifact` flips an
|
||||
* artifact's status to `Ready`. The chat runtime upserts the snapshot
|
||||
* keyed on `artifact_id` so the `ArtifactCard` can render in the
|
||||
* message timeline with a download button (#2779).
|
||||
*/
|
||||
export interface ArtifactReadyEvent {
|
||||
thread_id: string;
|
||||
client_id?: string;
|
||||
/** UUID of the artifact record. Use with `ai_get_artifact`. */
|
||||
artifact_id: string;
|
||||
kind: ArtifactKind;
|
||||
/** Human-readable title; also the on-disk filename stem. */
|
||||
title: string;
|
||||
/** Relative path under `<workspace>/artifacts/`, e.g. `<uuid>/deck.pptx`. */
|
||||
path: string;
|
||||
/** Final on-disk size in bytes. */
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when `artifacts::store::fail_artifact` flips an artifact to
|
||||
* `Failed` after the producer surfaced a reason. The frontend swaps
|
||||
* the in-flight card for a retry-hint view.
|
||||
*/
|
||||
export interface ArtifactFailedEvent {
|
||||
thread_id: string;
|
||||
client_id?: string;
|
||||
artifact_id: string;
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
/** Producer-supplied failure reason, already truncated. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Emitted when the agent turn begins (before the first LLM call). */
|
||||
export interface ChatInferenceStartEvent {
|
||||
thread_id: string;
|
||||
@@ -354,6 +396,8 @@ export interface ChatEventListeners {
|
||||
onTaskBoardUpdated?: (event: ChatTaskBoardUpdatedEvent) => void;
|
||||
onProactiveMessage?: (event: ProactiveMessageEvent) => void;
|
||||
onApprovalRequest?: (event: ChatApprovalRequestEvent) => void;
|
||||
onArtifactReady?: (event: ArtifactReadyEvent) => void;
|
||||
onArtifactFailed?: (event: ArtifactFailedEvent) => void;
|
||||
onDone?: (event: ChatDoneEvent) => void;
|
||||
onError?: (event: ChatErrorEvent) => void;
|
||||
}
|
||||
@@ -387,6 +431,8 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
|
||||
taskBoardUpdated: 'task_board_updated',
|
||||
proactiveMessage: 'proactive_message',
|
||||
approvalRequest: 'approval_request',
|
||||
artifactReady: 'artifact_ready',
|
||||
artifactFailed: 'artifact_failed',
|
||||
done: 'chat_done',
|
||||
error: 'chat_error',
|
||||
} as const;
|
||||
@@ -706,6 +752,113 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
|
||||
handlers.push([EVENTS.approvalRequest, cb]);
|
||||
}
|
||||
|
||||
// Artifact lifecycle events (#2779). The Rust subscriber in
|
||||
// `channels/providers/web::ArtifactSurfaceSubscriber` packs the
|
||||
// artifact payload into the generic `args` field of the wire
|
||||
// envelope (kept the WebChannelEvent struct shape stable to avoid
|
||||
// touching ~10 existing call sites with `..Default::default()`).
|
||||
// Flatten back into the typed `ArtifactReadyEvent` /
|
||||
// `ArtifactFailedEvent` shape so listeners get a clean contract.
|
||||
const validArtifactKinds: ReadonlySet<ArtifactKind> = new Set([
|
||||
'presentation',
|
||||
'document',
|
||||
'image',
|
||||
'other',
|
||||
]);
|
||||
const isValidArtifactKind = (k: unknown): k is ArtifactKind =>
|
||||
typeof k === 'string' && validArtifactKinds.has(k as ArtifactKind);
|
||||
if (listeners.onArtifactReady) {
|
||||
const cb = (payload: unknown) => {
|
||||
const raw = payload as {
|
||||
thread_id: string;
|
||||
client_id?: string;
|
||||
args?: {
|
||||
artifact_id?: string;
|
||||
kind?: ArtifactKind;
|
||||
title?: string;
|
||||
path?: string;
|
||||
size_bytes?: number;
|
||||
};
|
||||
};
|
||||
const args = raw.args ?? {};
|
||||
if (
|
||||
!args.artifact_id ||
|
||||
!isValidArtifactKind(args.kind) ||
|
||||
!args.title ||
|
||||
!args.path ||
|
||||
args.size_bytes == null
|
||||
) {
|
||||
chatLog(
|
||||
'%s thread_id=%s — skipping malformed payload (missing args)',
|
||||
EVENTS.artifactReady,
|
||||
raw.thread_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
const event: ArtifactReadyEvent = {
|
||||
thread_id: raw.thread_id,
|
||||
client_id: raw.client_id,
|
||||
artifact_id: args.artifact_id,
|
||||
kind: args.kind,
|
||||
title: args.title,
|
||||
path: args.path,
|
||||
size_bytes: args.size_bytes,
|
||||
};
|
||||
chatLog(
|
||||
'%s thread_id=%s artifact_id=%s kind=%s size=%d',
|
||||
EVENTS.artifactReady,
|
||||
event.thread_id,
|
||||
event.artifact_id,
|
||||
event.kind,
|
||||
event.size_bytes
|
||||
);
|
||||
listeners.onArtifactReady?.(event);
|
||||
};
|
||||
socket.on(EVENTS.artifactReady, cb);
|
||||
handlers.push([EVENTS.artifactReady, cb]);
|
||||
}
|
||||
|
||||
if (listeners.onArtifactFailed) {
|
||||
const cb = (payload: unknown) => {
|
||||
const raw = payload as {
|
||||
thread_id: string;
|
||||
client_id?: string;
|
||||
args?: { artifact_id?: string; kind?: ArtifactKind; title?: string; error?: string };
|
||||
};
|
||||
const args = raw.args ?? {};
|
||||
if (!args.artifact_id || !isValidArtifactKind(args.kind) || !args.title || !args.error) {
|
||||
chatLog(
|
||||
'%s thread_id=%s — skipping malformed payload (missing args)',
|
||||
EVENTS.artifactFailed,
|
||||
raw.thread_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
const event: ArtifactFailedEvent = {
|
||||
thread_id: raw.thread_id,
|
||||
client_id: raw.client_id,
|
||||
artifact_id: args.artifact_id,
|
||||
kind: args.kind,
|
||||
title: args.title,
|
||||
error: args.error,
|
||||
};
|
||||
// Defence-in-depth: producer is expected to pre-truncate, but
|
||||
// cap the log preview again so a leaky producer cannot blast
|
||||
// unbounded provider stderr into client telemetry.
|
||||
chatLog(
|
||||
'%s thread_id=%s artifact_id=%s kind=%s err=%s',
|
||||
EVENTS.artifactFailed,
|
||||
event.thread_id,
|
||||
event.artifact_id,
|
||||
event.kind,
|
||||
event.error.slice(0, 80)
|
||||
);
|
||||
listeners.onArtifactFailed?.(event);
|
||||
};
|
||||
socket.on(EVENTS.artifactFailed, cb);
|
||||
handlers.push([EVENTS.artifactFailed, cb]);
|
||||
}
|
||||
|
||||
if (listeners.onTaskBoardUpdated) {
|
||||
const cb = (payload: unknown) => {
|
||||
const e = payload as ChatTaskBoardUpdatedEvent;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import reducer, {
|
||||
clearAllChatRuntime,
|
||||
clearArtifactsForThread,
|
||||
clearRuntimeForThread,
|
||||
upsertArtifactFailedForThread,
|
||||
upsertArtifactInProgressForThread,
|
||||
upsertArtifactReadyForThread,
|
||||
} from '../chatRuntimeSlice';
|
||||
|
||||
describe('chatRuntimeSlice — artifact lifecycle (#2779)', () => {
|
||||
it('upserts a new in_progress snapshot for a thread', () => {
|
||||
const next = reducer(
|
||||
undefined,
|
||||
upsertArtifactInProgressForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
})
|
||||
);
|
||||
|
||||
const list = next.artifactsByThread['t-1'];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
status: 'in_progress',
|
||||
});
|
||||
expect(typeof list[0].updatedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('promotes the in_progress entry to ready in place, preserving order', () => {
|
||||
const inProgress = reducer(
|
||||
undefined,
|
||||
upsertArtifactInProgressForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
})
|
||||
);
|
||||
const withSibling = reducer(
|
||||
inProgress,
|
||||
upsertArtifactInProgressForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-2',
|
||||
kind: 'document',
|
||||
title: 'Notes',
|
||||
})
|
||||
);
|
||||
|
||||
const ready = reducer(
|
||||
withSibling,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
sizeBytes: 4096,
|
||||
})
|
||||
);
|
||||
|
||||
const list = ready.artifactsByThread['t-1'];
|
||||
expect(list).toHaveLength(2);
|
||||
// first slot stays first — replaced in place, not appended.
|
||||
expect(list[0]).toMatchObject({
|
||||
artifactId: 'a-1',
|
||||
status: 'ready',
|
||||
path: 'a-1/deck.pptx',
|
||||
sizeBytes: 4096,
|
||||
});
|
||||
expect(list[1].artifactId).toBe('a-2');
|
||||
});
|
||||
|
||||
it('records a failed artifact with the producer reason', () => {
|
||||
const next = reducer(
|
||||
undefined,
|
||||
upsertArtifactFailedForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: 'pip install failed',
|
||||
})
|
||||
);
|
||||
|
||||
expect(next.artifactsByThread['t-1'][0]).toMatchObject({
|
||||
artifactId: 'a-1',
|
||||
status: 'failed',
|
||||
error: 'pip install failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends new artifactIds in insertion order', () => {
|
||||
const a = reducer(
|
||||
undefined,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'document',
|
||||
title: 'Doc',
|
||||
path: 'a-1/doc.pdf',
|
||||
sizeBytes: 100,
|
||||
})
|
||||
);
|
||||
const b = reducer(
|
||||
a,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-2',
|
||||
kind: 'image',
|
||||
title: 'Pic',
|
||||
path: 'a-2/pic.png',
|
||||
sizeBytes: 200,
|
||||
})
|
||||
);
|
||||
|
||||
const ids = b.artifactsByThread['t-1'].map(entry => entry.artifactId);
|
||||
expect(ids).toEqual(['a-1', 'a-2']);
|
||||
});
|
||||
|
||||
it('keeps thread buckets isolated', () => {
|
||||
const a = reducer(
|
||||
undefined,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'document',
|
||||
title: 'Doc',
|
||||
path: 'a-1/doc.pdf',
|
||||
sizeBytes: 100,
|
||||
})
|
||||
);
|
||||
const b = reducer(
|
||||
a,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-2',
|
||||
artifactId: 'a-2',
|
||||
kind: 'image',
|
||||
title: 'Pic',
|
||||
path: 'a-2/pic.png',
|
||||
sizeBytes: 200,
|
||||
})
|
||||
);
|
||||
|
||||
expect(b.artifactsByThread['t-1']).toHaveLength(1);
|
||||
expect(b.artifactsByThread['t-2']).toHaveLength(1);
|
||||
expect(b.artifactsByThread['t-1'][0].artifactId).toBe('a-1');
|
||||
expect(b.artifactsByThread['t-2'][0].artifactId).toBe('a-2');
|
||||
});
|
||||
|
||||
it('clearArtifactsForThread drops just that thread bucket', () => {
|
||||
const populated = reducer(
|
||||
undefined,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'image',
|
||||
title: 'P',
|
||||
path: 'a-1/p.png',
|
||||
sizeBytes: 1,
|
||||
})
|
||||
);
|
||||
const cleared = reducer(populated, clearArtifactsForThread({ threadId: 't-1' }));
|
||||
expect(cleared.artifactsByThread['t-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearRuntimeForThread does NOT wipe artifact history', () => {
|
||||
// ArtifactCard renders inline in the message timeline so the snapshot
|
||||
// must survive turn boundaries — historic artifacts stay visible.
|
||||
const populated = reducer(
|
||||
undefined,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'image',
|
||||
title: 'P',
|
||||
path: 'a-1/p.png',
|
||||
sizeBytes: 1,
|
||||
})
|
||||
);
|
||||
const cleared = reducer(populated, clearRuntimeForThread({ threadId: 't-1' }));
|
||||
expect(cleared.artifactsByThread['t-1']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clearAllChatRuntime drops all artifact buckets', () => {
|
||||
const populated = reducer(
|
||||
undefined,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'image',
|
||||
title: 'P',
|
||||
path: 'a-1/p.png',
|
||||
sizeBytes: 1,
|
||||
})
|
||||
);
|
||||
const cleared = reducer(populated, clearAllChatRuntime());
|
||||
expect(cleared.artifactsByThread).toEqual({});
|
||||
});
|
||||
|
||||
it('failed -> ready replays in place without duplicating', () => {
|
||||
// Useful if a producer retries: failed snapshot should be replaced
|
||||
// by ready, not stacked.
|
||||
const failed = reducer(
|
||||
undefined,
|
||||
upsertArtifactFailedForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
error: 'boom',
|
||||
})
|
||||
);
|
||||
const ready = reducer(
|
||||
failed,
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: 't-1',
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Deck',
|
||||
path: 'a-1/deck.pptx',
|
||||
sizeBytes: 1024,
|
||||
})
|
||||
);
|
||||
|
||||
const list = ready.artifactsByThread['t-1'];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].status).toBe('ready');
|
||||
expect(list[0].error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,41 @@ export interface PendingApproval {
|
||||
command?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of a single agent-generated artifact, as projected
|
||||
* onto the chat runtime per thread.
|
||||
*
|
||||
* - `in_progress` — derived: the producing tool call is in flight; we
|
||||
* have not yet seen a ready/failed event. UI shows a spinner.
|
||||
* - `ready` — `artifact_ready` socket event received. UI shows a
|
||||
* download button.
|
||||
* - `failed` — `artifact_failed` socket event received. UI shows the
|
||||
* reason + a retry hint.
|
||||
*/
|
||||
export type ArtifactStatus = 'in_progress' | 'ready' | 'failed';
|
||||
|
||||
/**
|
||||
* Per-thread snapshot of a single artifact's state. Upserted from
|
||||
* artifact lifecycle socket events; consumed by `ArtifactCard` for
|
||||
* inline message rendering (#2779).
|
||||
*/
|
||||
export interface ArtifactSnapshot {
|
||||
artifactId: string;
|
||||
/** Kind slug from the Rust `ArtifactKind` enum. */
|
||||
kind: 'presentation' | 'document' | 'image' | 'other';
|
||||
/** Human-readable title; also the on-disk filename stem. */
|
||||
title: string;
|
||||
status: ArtifactStatus;
|
||||
/** Final on-disk size. Only set when `status === 'ready'`. */
|
||||
sizeBytes?: number;
|
||||
/** Relative path under `<workspace>/artifacts/`. Only set when `status === 'ready'`. */
|
||||
path?: string;
|
||||
/** Producer-supplied reason. Only set when `status === 'failed'`. */
|
||||
error?: string;
|
||||
/** When the snapshot was last updated, milliseconds since epoch. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-thread UI state for an in-flight agent turn (socket events while the user
|
||||
* may navigate away from Conversations). The thread slice keeps `activeThreadId`
|
||||
@@ -200,6 +235,13 @@ interface ChatRuntimeState {
|
||||
taskBoardByThread: Record<string, TaskBoard>;
|
||||
inferenceTurnLifecycleByThread: Record<string, InferenceTurnLifecycle>;
|
||||
pendingApprovalByThread: Record<string, PendingApproval>;
|
||||
/**
|
||||
* Per-thread artifact ledger. Snapshots are upserted on
|
||||
* `artifact_ready` / `artifact_failed` socket events keyed on
|
||||
* `artifactId`. `ArtifactCard` reads this slice to render inline
|
||||
* download / retry affordances (#2779).
|
||||
*/
|
||||
artifactsByThread: Record<string, ArtifactSnapshot[]>;
|
||||
sessionTokenUsage: SessionTokenUsage;
|
||||
}
|
||||
|
||||
@@ -210,9 +252,30 @@ const initialState: ChatRuntimeState = {
|
||||
taskBoardByThread: {},
|
||||
inferenceTurnLifecycleByThread: {},
|
||||
pendingApprovalByThread: {},
|
||||
artifactsByThread: {},
|
||||
sessionTokenUsage: { inputTokens: 0, outputTokens: 0, turns: 0, lastUpdated: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert a single artifact snapshot for a thread. New entries append
|
||||
* in insertion order (matches the timeline ordering the UI expects);
|
||||
* existing entries are replaced in place so the inline card flips
|
||||
* status without remounting.
|
||||
*/
|
||||
function upsertArtifact(
|
||||
bucket: ArtifactSnapshot[] | undefined,
|
||||
snapshot: ArtifactSnapshot
|
||||
): ArtifactSnapshot[] {
|
||||
const list = bucket ?? [];
|
||||
const idx = list.findIndex(entry => entry.artifactId === snapshot.artifactId);
|
||||
if (idx === -1) {
|
||||
return [...list, snapshot];
|
||||
}
|
||||
const next = list.slice();
|
||||
next[idx] = snapshot;
|
||||
return next;
|
||||
}
|
||||
|
||||
function subagentToolCallFromPersisted(call: PersistedSubagentToolCall): SubagentToolCallEntry {
|
||||
return {
|
||||
callId: call.callId,
|
||||
@@ -406,6 +469,99 @@ const chatRuntimeSlice = createSlice({
|
||||
clearPendingApprovalForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.pendingApprovalByThread[action.payload.threadId];
|
||||
},
|
||||
/**
|
||||
* Mark a producer-tool call as in-flight so the `ArtifactCard` can
|
||||
* render a spinner before any ready/failed event arrives. Caller
|
||||
* usually fires this off the corresponding `ChatToolCallEvent`
|
||||
* when the tool is in the known artifact-producing allowlist
|
||||
* (e.g. `generate_presentation`). Re-firing for the same
|
||||
* `artifactId` is a no-op (idempotent upsert).
|
||||
*/
|
||||
upsertArtifactInProgressForThread: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
artifactId: string;
|
||||
kind: ArtifactSnapshot['kind'];
|
||||
title: string;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, artifactId, kind, title } = action.payload;
|
||||
const snapshot: ArtifactSnapshot = {
|
||||
artifactId,
|
||||
kind,
|
||||
title,
|
||||
status: 'in_progress',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
state.artifactsByThread[threadId] = upsertArtifact(
|
||||
state.artifactsByThread[threadId],
|
||||
snapshot
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Mark an artifact as ready (download-able). Triggered by the
|
||||
* `artifact_ready` socket event. Promotes status off `in_progress`
|
||||
* and fills in `path` / `sizeBytes` for the download flow.
|
||||
*/
|
||||
upsertArtifactReadyForThread: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
artifactId: string;
|
||||
kind: ArtifactSnapshot['kind'];
|
||||
title: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, artifactId, kind, title, path, sizeBytes } = action.payload;
|
||||
const snapshot: ArtifactSnapshot = {
|
||||
artifactId,
|
||||
kind,
|
||||
title,
|
||||
status: 'ready',
|
||||
path,
|
||||
sizeBytes,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
state.artifactsByThread[threadId] = upsertArtifact(
|
||||
state.artifactsByThread[threadId],
|
||||
snapshot
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Mark an artifact as failed. Triggered by the `artifact_failed`
|
||||
* socket event. Promotes status off `in_progress` and persists the
|
||||
* producer-supplied `error` so the card can show a retry hint.
|
||||
*/
|
||||
upsertArtifactFailedForThread: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
artifactId: string;
|
||||
kind: ArtifactSnapshot['kind'];
|
||||
title: string;
|
||||
error: string;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, artifactId, kind, title, error } = action.payload;
|
||||
const snapshot: ArtifactSnapshot = {
|
||||
artifactId,
|
||||
kind,
|
||||
title,
|
||||
status: 'failed',
|
||||
error,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
state.artifactsByThread[threadId] = upsertArtifact(
|
||||
state.artifactsByThread[threadId],
|
||||
snapshot
|
||||
);
|
||||
},
|
||||
clearArtifactsForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.artifactsByThread[action.payload.threadId];
|
||||
},
|
||||
beginInferenceTurn: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
state.inferenceTurnLifecycleByThread[action.payload.threadId] = 'started';
|
||||
},
|
||||
@@ -424,6 +580,11 @@ const chatRuntimeSlice = createSlice({
|
||||
delete state.taskBoardByThread[action.payload.threadId];
|
||||
delete state.inferenceTurnLifecycleByThread[action.payload.threadId];
|
||||
delete state.pendingApprovalByThread[action.payload.threadId];
|
||||
// Note: artifactsByThread intentionally NOT cleared here. The
|
||||
// ArtifactCard renders inline in the message timeline, so the
|
||||
// snapshot needs to survive turn boundaries — historic artifacts
|
||||
// stay visible alongside the messages that produced them. Use
|
||||
// `clearArtifactsForThread` if a hard reset is desired.
|
||||
},
|
||||
clearAllChatRuntime: state => {
|
||||
state.inferenceStatusByThread = {};
|
||||
@@ -432,6 +593,7 @@ const chatRuntimeSlice = createSlice({
|
||||
state.taskBoardByThread = {};
|
||||
state.inferenceTurnLifecycleByThread = {};
|
||||
state.pendingApprovalByThread = {};
|
||||
state.artifactsByThread = {};
|
||||
},
|
||||
recordChatTurnUsage: (
|
||||
state,
|
||||
@@ -521,6 +683,10 @@ export const {
|
||||
clearTaskBoardForThread,
|
||||
setPendingApprovalForThread,
|
||||
clearPendingApprovalForThread,
|
||||
upsertArtifactInProgressForThread,
|
||||
upsertArtifactReadyForThread,
|
||||
upsertArtifactFailedForThread,
|
||||
clearArtifactsForThread,
|
||||
beginInferenceTurn,
|
||||
markInferenceTurnStreaming,
|
||||
endInferenceTurn,
|
||||
|
||||
@@ -119,7 +119,13 @@ test.describe('Chat Tool Error Recovery', () => {
|
||||
const threadId = await createNewThread(page);
|
||||
await sendMessage(page, 'Tell me something important.');
|
||||
|
||||
await expect(page.getByText('Starting to answer')).toBeVisible({ timeout: 20_000 });
|
||||
// Two elements briefly carry the streamed text: the live-streaming
|
||||
// preview block (`font-mono` console-style render in Conversations.tsx)
|
||||
// and the persisted message bubble that takes over once the segment
|
||||
// commits. Playwright strict-mode trips when both are simultaneously
|
||||
// visible during the transition. `.first()` keeps the assertion robust
|
||||
// — we just care that the streamed substring rendered somewhere.
|
||||
await expect(page.getByText('Starting to answer').first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { bootAuthenticatedPage, waitForAppReady } from '../helpers/core-rpc';
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
test.describe('Insights Dashboard', () => {
|
||||
test('renders the memory workspace and actions toolbar', async ({ page }) => {
|
||||
await bootAuthenticatedPage(page, 'pw-insights-user', '/intelligence');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// The Intelligence page now defaults to the "Agent Tasks" tab (#2998), so
|
||||
// select the Memory tab before asserting its workspace renders.
|
||||
// /intelligence boots on the Tasks tab (default in Intelligence.tsx since
|
||||
// #2998). The walkthrough portal can sit on top of the pill bar and the
|
||||
// memory-workspace testid only mounts when tab=memory, so we dismiss the
|
||||
// overlay and click the Memory pill before asserting the panel chrome.
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
await page.getByRole('tab', { name: 'Memory', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid="memory-workspace"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="memory-actions"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -352,6 +352,52 @@ pub enum DomainEvent {
|
||||
decision: String,
|
||||
},
|
||||
|
||||
// ── Artifacts ───────────────────────────────────────────────────────
|
||||
/// An artifact transitioned to [`ArtifactStatus::Ready`] — file
|
||||
/// is on disk and ready to be downloaded. Published by
|
||||
/// [`crate::openhuman::artifacts::store::finalize_artifact`].
|
||||
/// Bridged to the web channel as an `artifact_ready` socket event
|
||||
/// when the publishing turn carries an `APPROVAL_CHAT_CONTEXT`
|
||||
/// (see [`crate::openhuman::approval::ApprovalChatContext`]).
|
||||
/// Sub-task #2779 of #1535.
|
||||
ArtifactReady {
|
||||
/// UUID of the artifact record.
|
||||
artifact_id: String,
|
||||
/// Lowercase variant of `ArtifactKind` (`presentation`,
|
||||
/// `document`, `image`, `other`).
|
||||
kind: String,
|
||||
/// Human-readable title (also the on-disk filename stem).
|
||||
title: String,
|
||||
/// Relative path under `<workspace>/artifacts/`, e.g.
|
||||
/// `"<uuid>/deck.pptx"`. The absolute path is reachable via
|
||||
/// `ai_get_artifact` so the renderer never needs the
|
||||
/// workspace root.
|
||||
path: String,
|
||||
/// Final on-disk file size in bytes.
|
||||
size_bytes: u64,
|
||||
/// Chat thread the artifact belongs to, when the producing
|
||||
/// turn carried an `APPROVAL_CHAT_CONTEXT`. `None` for CLI /
|
||||
/// cron / sub-agent paths — no client to fan out to.
|
||||
thread_id: Option<String>,
|
||||
/// Socket.IO client id (room) to surface the card to, when
|
||||
/// known. `None` for non-chat callers.
|
||||
client_id: Option<String>,
|
||||
},
|
||||
/// An artifact transitioned to [`ArtifactStatus::Failed`] — the
|
||||
/// producer surfaced a reason and the UI should render a
|
||||
/// retry-hint card instead of a download. Bridged the same way
|
||||
/// as [`Self::ArtifactReady`]. Sub-task #2779 of #1535.
|
||||
ArtifactFailed {
|
||||
artifact_id: String,
|
||||
kind: String,
|
||||
title: String,
|
||||
/// Producer-supplied failure reason. Already truncated by the
|
||||
/// producer (e.g. `PresentationError::truncate_stderr`).
|
||||
error: String,
|
||||
thread_id: Option<String>,
|
||||
client_id: Option<String>,
|
||||
},
|
||||
|
||||
// ── Webhooks ────────────────────────────────────────────────────────
|
||||
/// An incoming webhook request from the transport layer, ready for routing.
|
||||
WebhookIncomingRequest {
|
||||
@@ -846,6 +892,8 @@ impl DomainEvent {
|
||||
|
||||
Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval",
|
||||
|
||||
Self::ArtifactReady { .. } | Self::ArtifactFailed { .. } => "artifact",
|
||||
|
||||
Self::McpServerInstalled { .. }
|
||||
| Self::McpServerConnected { .. }
|
||||
| Self::McpServerDisconnected { .. }
|
||||
@@ -934,6 +982,8 @@ impl DomainEvent {
|
||||
Self::SessionExpired { .. } => "SessionExpired",
|
||||
Self::ApprovalRequested { .. } => "ApprovalRequested",
|
||||
Self::ApprovalDecided { .. } => "ApprovalDecided",
|
||||
Self::ArtifactReady { .. } => "ArtifactReady",
|
||||
Self::ArtifactFailed { .. } => "ArtifactFailed",
|
||||
Self::McpServerInstalled { .. } => "McpServerInstalled",
|
||||
Self::McpServerConnected { .. } => "McpServerConnected",
|
||||
Self::McpServerDisconnected { .. } => "McpServerDisconnected",
|
||||
|
||||
@@ -2081,6 +2081,13 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
|
||||
Prompt-class external-effect tool calls run unprompted"
|
||||
);
|
||||
}
|
||||
// Artifact surface bridges DomainEvent::ArtifactReady/Failed onto the web
|
||||
// channel ("Files in this chat" panel + ArtifactCard updates). This is
|
||||
// independent of the approval-gate config — keep it outside the
|
||||
// `if approval_gate` block so artifact events still publish when the user
|
||||
// sets OPENHUMAN_APPROVAL_GATE=0 (CR #3328947323 on PR #3026). Idempotent
|
||||
// (OnceLock-guarded inside register_artifact_surface_subscriber).
|
||||
crate::openhuman::channels::providers::web::register_artifact_surface_subscriber();
|
||||
|
||||
// --- Workspace migrations --------------------------------------------
|
||||
crate::openhuman::startup::run_workspace_migrations(&workspace_dir);
|
||||
|
||||
@@ -160,7 +160,6 @@ named = [
|
||||
# Both are routed through the same security/approval gate as `shell`.
|
||||
"workflow_load",
|
||||
"workflow_phase",
|
||||
# Presentation generation (#2778). Synthesises a .pptx from a
|
||||
# structured slide spec via a native Rust engine (`ppt-rs`) running
|
||||
# in-process — no Python subprocess, no managed venv. Output lands
|
||||
# in the workspace artifacts directory and the tool returns the
|
||||
|
||||
@@ -348,13 +348,27 @@ pub async fn create_artifact(
|
||||
/// Flip a pending artifact to [`ArtifactStatus::Ready`] and persist
|
||||
/// the final size. Idempotent on already-ready artifacts (no-op + log).
|
||||
/// Returns the updated metadata.
|
||||
///
|
||||
/// On a real transition (Pending → Ready), publishes
|
||||
/// [`DomainEvent::ArtifactReady`] on the global bus so the web
|
||||
/// channel can surface a download card to the originating thread.
|
||||
/// When the calling task carries no
|
||||
/// [`ApprovalChatContext`](crate::openhuman::approval::ApprovalChatContext)
|
||||
/// (CLI / cron / sub-agent paths), the event is still published but
|
||||
/// `thread_id` / `client_id` are `None` so the socket bridge silently
|
||||
/// drops it. Idempotent calls (already-Ready) skip the publish so we
|
||||
/// don't flap the UI.
|
||||
pub async fn finalize_artifact(
|
||||
workspace_dir: &Path,
|
||||
artifact_id: &str,
|
||||
size_bytes: u64,
|
||||
) -> Result<ArtifactMeta, String> {
|
||||
let mut meta = get_artifact(workspace_dir, artifact_id).await?;
|
||||
if matches!(meta.status, ArtifactStatus::Ready) && meta.size_bytes == size_bytes {
|
||||
if matches!(meta.status, ArtifactStatus::Ready) {
|
||||
// Idempotent on status alone: a second finalize with a different
|
||||
// size_bytes shouldn't re-emit ArtifactReady and flap the UI card
|
||||
// — once an artifact is Ready, callers should not be redefining
|
||||
// its size. Per graycyrus on PR #3017.
|
||||
log::debug!("[artifacts] finalize_artifact: id={artifact_id} already Ready, no-op");
|
||||
return Ok(meta);
|
||||
}
|
||||
@@ -363,6 +377,17 @@ pub async fn finalize_artifact(
|
||||
meta.error = None;
|
||||
save_artifact_meta(workspace_dir, &meta).await?;
|
||||
log::debug!("[artifacts] finalize_artifact: id={artifact_id} -> Ready size={size_bytes}");
|
||||
|
||||
let (thread_id, client_id) = current_chat_context();
|
||||
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::ArtifactReady {
|
||||
artifact_id: meta.id.clone(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
title: meta.title.clone(),
|
||||
path: meta.path.clone(),
|
||||
size_bytes: meta.size_bytes,
|
||||
thread_id,
|
||||
client_id,
|
||||
});
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
@@ -370,6 +395,10 @@ pub async fn finalize_artifact(
|
||||
/// failure reason. The producer should call this when generation
|
||||
/// fails so the UI / RPC consumer can surface a useful message
|
||||
/// instead of an indefinite spinner. Returns the updated metadata.
|
||||
///
|
||||
/// Publishes [`DomainEvent::ArtifactFailed`] so the chat surface
|
||||
/// flips the in-flight card to a retry-hint state. Same chat-context
|
||||
/// rules as [`finalize_artifact`].
|
||||
pub async fn fail_artifact(
|
||||
workspace_dir: &Path,
|
||||
artifact_id: &str,
|
||||
@@ -379,10 +408,39 @@ pub async fn fail_artifact(
|
||||
meta.status = ArtifactStatus::Failed;
|
||||
meta.error = Some(reason.to_string());
|
||||
save_artifact_meta(workspace_dir, &meta).await?;
|
||||
log::warn!("[artifacts] fail_artifact: id={artifact_id} -> Failed reason={reason:?}");
|
||||
// Log only the size of the reason — it can carry provider stderr
|
||||
// / user-derived content, which we don't want flushed verbatim
|
||||
// into structured logs. The full payload is still persisted on
|
||||
// `meta.error` for the UI surface and the chat event below.
|
||||
log::warn!(
|
||||
"[artifacts] fail_artifact: id={artifact_id} -> Failed reason_len={}",
|
||||
reason.len()
|
||||
);
|
||||
|
||||
let (thread_id, client_id) = current_chat_context();
|
||||
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::ArtifactFailed {
|
||||
artifact_id: meta.id.clone(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
title: meta.title.clone(),
|
||||
error: reason.to_string(),
|
||||
thread_id,
|
||||
client_id,
|
||||
});
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Read the active [`ApprovalChatContext`] task-local (set by
|
||||
/// `channels::providers::web` around each chat turn) and return its
|
||||
/// thread + client ids. Returns `(None, None)` for non-chat callers
|
||||
/// (CLI, cron, sub-agent runners) so artifact emit hooks degrade
|
||||
/// gracefully — the event is still published but the web subscriber
|
||||
/// drops it for lack of a routing target.
|
||||
fn current_chat_context() -> (Option<String>, Option<String>) {
|
||||
crate::openhuman::approval::APPROVAL_CHAT_CONTEXT
|
||||
.try_with(|ctx| (Some(ctx.thread_id.clone()), Some(ctx.client_id.clone())))
|
||||
.unwrap_or((None, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -62,6 +62,120 @@ pub fn register_approval_surface_subscriber() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for the artifact-surface subscriber. Set once on
|
||||
/// [`register_artifact_surface_subscriber`]; subsequent calls no-op.
|
||||
static ARTIFACT_SURFACE_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
|
||||
|
||||
/// Bridge artifact lifecycle events onto the web channel.
|
||||
/// `DomainEvent::ArtifactReady` / `ArtifactFailed` (published by
|
||||
/// `artifacts::store::{finalize,fail}_artifact` from #2778's producer
|
||||
/// surface) carry the thread_id + client_id when the producing turn
|
||||
/// ran under an `APPROVAL_CHAT_CONTEXT`. When present, fan out as an
|
||||
/// `artifact_ready` / `artifact_failed` socket event so the frontend
|
||||
/// `chatRuntimeSlice` can upsert the snapshot and the `ArtifactCard`
|
||||
/// can render in the message timeline. Sub-task #2779 of #1535.
|
||||
/// Idempotent. No-op for non-chat events (thread/client id absent).
|
||||
pub fn register_artifact_surface_subscriber() {
|
||||
if ARTIFACT_SURFACE_HANDLE.get().is_some() {
|
||||
return;
|
||||
}
|
||||
match crate::core::event_bus::subscribe_global(Arc::new(ArtifactSurfaceSubscriber)) {
|
||||
Some(handle) => {
|
||||
let _ = ARTIFACT_SURFACE_HANDLE.set(handle);
|
||||
log::info!(
|
||||
"[web-channel] artifact-surface subscriber registered (domain=artifact) — will bridge ArtifactReady/Failed → artifact_ready/artifact_failed socket events"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"[web-channel] failed to register artifact-surface subscriber — bus not initialized"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtifactSurfaceSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ArtifactSurfaceSubscriber {
|
||||
fn name(&self) -> &str {
|
||||
"channels::web::artifact_surface"
|
||||
}
|
||||
|
||||
fn domains(&self) -> Option<&[&str]> {
|
||||
Some(&["artifact"])
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
match event {
|
||||
DomainEvent::ArtifactReady {
|
||||
artifact_id,
|
||||
kind,
|
||||
title,
|
||||
path,
|
||||
size_bytes,
|
||||
thread_id,
|
||||
client_id,
|
||||
} => {
|
||||
let (Some(thread_id), Some(client_id)) = (thread_id, client_id) else {
|
||||
log::debug!(
|
||||
"[web-channel] artifact-surface skip ArtifactReady id={artifact_id}: no chat context"
|
||||
);
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"[web-channel] artifact-surface emitting artifact_ready id={artifact_id} kind={kind} thread_id={thread_id} client_id={client_id}"
|
||||
);
|
||||
publish_web_channel_event(WebChannelEvent {
|
||||
event: "artifact_ready".to_string(),
|
||||
client_id: client_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
args: Some(serde_json::json!({
|
||||
"artifact_id": artifact_id,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"path": path,
|
||||
"size_bytes": size_bytes,
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
DomainEvent::ArtifactFailed {
|
||||
artifact_id,
|
||||
kind,
|
||||
title,
|
||||
error,
|
||||
thread_id,
|
||||
client_id,
|
||||
} => {
|
||||
let (Some(thread_id), Some(client_id)) = (thread_id, client_id) else {
|
||||
log::debug!(
|
||||
"[web-channel] artifact-surface skip ArtifactFailed id={artifact_id}: no chat context"
|
||||
);
|
||||
return;
|
||||
};
|
||||
log::warn!(
|
||||
"[web-channel] artifact-surface emitting artifact_failed id={artifact_id} kind={kind} thread_id={thread_id} client_id={client_id} error_len={}",
|
||||
error.len()
|
||||
);
|
||||
publish_web_channel_event(WebChannelEvent {
|
||||
event: "artifact_failed".to_string(),
|
||||
client_id: client_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
args: Some(serde_json::json!({
|
||||
"artifact_id": artifact_id,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"error": error,
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalSurfaceSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -56,6 +56,10 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
// Surface parked ApprovalGate requests as chat messages so the user can
|
||||
// answer yes/no in the thread (chat-native approval, issue #1339).
|
||||
crate::openhuman::channels::providers::web::register_approval_surface_subscriber();
|
||||
// Surface generated-artifact lifecycle events (ArtifactReady /
|
||||
// ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel
|
||||
// events so the frontend ArtifactCard can render in chat (#2779).
|
||||
crate::openhuman::channels::providers::web::register_artifact_surface_subscriber();
|
||||
// Spawn the per-toolkit provider periodic sync scheduler. This is
|
||||
// a thin tokio task that ticks every minute and dispatches into
|
||||
// any provider whose `sync_interval_secs` has elapsed for an
|
||||
|
||||
Reference in New Issue
Block a user