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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/agent-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
All notable changes to Muster Agent. Each `## <version>` section becomes the notes of the
`agent-v<version>` GitHub Release (`.github/workflows/agent-app-release.yml`).

## 0.3.5

- Outputs of a server project open on your Mac (#305). Every document, attachment and work product in a connected project's Outputs tab is a link: Muster downloads it with your own sign-in into a private cache (readable only by you, one folder per server, 50 MB at most) and shows it in its own viewer, with a spinner while it loads and a plain sentence if the server refuses or no longer has it. When the project is linked for Work locally and the same file exists in that folder, the local file opens instead. Each row also has Download… (a save dialog) and Open on server. Pull requests and links open as before, and local projects are unchanged.

## 0.3.4

- Work locally no longer needs a git repository (#303). Any folder can be linked to a project: a git repository still gets its own worktree and branch, and any other folder is used as it is, with "Working in <folder>" on screen. Muster notes the files at check-out, and progress and the hand-back list the files added, changed and removed instead of a git diff. A plain folder hands back when you say you are done in the local chat (and, if it has a test setup and tests ran, they passed after the last change); it never pushes and never opens a pull request.
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/agent-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@muster/agent-app",
"version": "0.3.4",
"version": "0.3.5",
"private": true,
"description": "Muster Code standalone agent desktop app (Electron, no Code-OSS extension host).",
"license": "MIT",
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-app/scripts/test-renderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// renderer-lifecycle plus the DOM suites that have no tests/run-*.mjs runner. Each sets up its own
// linkedom globals, so each gets its own process, one at a time.
const suites = ['renderer-lifecycle', 'folder-default-model', 'model-picker-dom', 'memory-recall-chip', 'summary-scheduled-dom', 'processes-components', 'skill-attach-components', 'subagent-components', 'mailbox-inbox-components', 'providers-components', 'browser-components', 'message-meta', 'markdown-fade-dom', 'timeline-navigation-dom', 'scoped-computer-components', 'sandbox-controls-components', 'settings-shell', 'workspace-refresh-e2e', 'stop-keeps-workspace', 'review-fixes-dom', 'file-tree-multiselect', 'pdf-thumbnails', 'artifact-viewer', 'project-team-components', 'project-surface-components', 'plugins-screen-components', 'parallel-run-guard-e2e', 'area-boundary-dom', 'setup-guide-dom', 'new-project-sources-components', 'hub-components', 'projects-parity-components', 'paperclip-integration-dom', 'connected-views-dom', 'paperclip-signin-dom', 'muster-server-components', 'governance-components', 'work-components', 'nav-components', 'org-components', 'orgs-dom'];
const SPLIT = new Set(['orgs-dom', 'browser-components', 'hub-components', 'projects-parity-components', 'paperclip-integration-dom', 'paperclip-signin-dom', 'work-components', 'nav-components', 'org-components']);
const suites = ['renderer-lifecycle', 'folder-default-model', 'model-picker-dom', 'memory-recall-chip', 'summary-scheduled-dom', 'processes-components', 'skill-attach-components', 'subagent-components', 'mailbox-inbox-components', 'providers-components', 'browser-components', 'message-meta', 'markdown-fade-dom', 'timeline-navigation-dom', 'scoped-computer-components', 'sandbox-controls-components', 'settings-shell', 'workspace-refresh-e2e', 'stop-keeps-workspace', 'review-fixes-dom', 'file-tree-multiselect', 'pdf-thumbnails', 'artifact-viewer', 'project-team-components', 'project-surface-components', 'plugins-screen-components', 'parallel-run-guard-e2e', 'area-boundary-dom', 'setup-guide-dom', 'new-project-sources-components', 'hub-components', 'projects-parity-components', 'paperclip-integration-dom', 'connected-views-dom', 'paperclip-signin-dom', 'muster-server-components', 'governance-components', 'work-components', 'nav-components', 'org-components', 'orgs-dom', 'server-outputs-dom'];
const SPLIT = new Set(['orgs-dom', 'browser-components', 'hub-components', 'projects-parity-components', 'paperclip-integration-dom', 'paperclip-signin-dom', 'work-components', 'nav-components', 'org-components', 'server-outputs-dom']);
let failed = 0;
// DOM suites drive React through linkedom with real timers; a loaded CI runner occasionally starves the first
// render past a suite's waits. A failing suite gets one clean re-run in a fresh process: a real regression fails
Expand Down
1 change: 1 addition & 0 deletions packages/agent-app/src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const COMMANDS = {
'files.trash': true,
'files.reveal': true,
'files.saveCopy': true,
'paperclip.output.save': true,
'files.search': true,
'files.read': true,
'files.asset': true,
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,18 @@ async function main(): Promise<void> {
await fs.copyFile(source,result.filePath);
return {saved:true,fileName:path.basename(result.filePath)};
}
if(command==='paperclip.output.save'){
if(!service||!window||window.isDestroyed())throw new Error('Agent service is unavailable.');
const request=input as Commands['paperclip.output.save']['input'];
if(!request||typeof request.id!=='string'||!request.id||request.id.length>200||(request.projectId!==undefined&&typeof request.projectId!=='string'))throw new Error('Invalid Download request.');
// The runtime fetches with the connection's own sign-in into the private cache; main only writes what the person chose.
const file=await service.invoke('paperclip.output.fetch',{id:request.id,...(request.projectId?{projectId:request.projectId}:{}),preferServer:true});
if(file.kind!=='cached'||!file.path)throw new Error('The server has no file to download for this output.');
const result=await dialog.showSaveDialog(window,{title:'Download',defaultPath:path.join(app.getPath('downloads'),file.name)});
if(result.canceled||!result.filePath)return {saved:false};
await fs.copyFile(file.path,result.filePath);
return {saved:true,fileName:path.basename(result.filePath)};
}
if(command==='chat.export.file'){
if(!service||!window||window.isDestroyed())throw new Error('Agent service is unavailable.');
const request=input as Commands['chat.export.file']['input'];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* A server output that Muster downloaded into its private cache, in Muster's own viewer: text and images inline, anything else with
* Open with / Reveal. Read-only. Download… and Open on server are here as well as on the row.
*/
import React, { useEffect, useState } from 'react';
import { Dialog } from '@base-ui/react/dialog';
import { Download, ExternalLink, FileText, FolderOpen, X } from 'lucide-react';
import type { OpenWithApp } from '../../shared/domains/files-protocol';
import type { ServerOutputFile, ServerOutputPreview } from '../../shared/domains/paperclip-protocol';
import { invoke } from '../bridge';
import { cleanIpcError } from './resourceErrors';
import './file-actions.css';
import './artifact-viewer.css';

type Load = { state: 'loading' } | { state: 'ready'; preview: ServerOutputPreview } | { state: 'error'; message: string };
const kb = (bytes: number) => bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;

export function ServerOutputContents({ file }: { file: ServerOutputFile }): React.ReactElement {
const [load, setLoad] = useState<Load>({ state: 'loading' });
const [apps, setApps] = useState<OpenWithApp[]>([]);
useEffect(() => {
let live = true; setLoad({ state: 'loading' });
void invoke('paperclip.output.preview', { path: file.path }).then(preview => { if (live) setLoad({ state: 'ready', preview }); }, error => { if (live) setLoad({ state: 'error', message: cleanIpcError(error) }); });
return () => { live = false; };
}, [file.path]);
const binary = load.state === 'ready' && load.preview.kind === 'binary';
useEffect(() => { if (binary) void invoke('files.openWith.apps', { path: file.path }).then(list => setApps(list.apps.filter(app => app.id !== 'finder').slice(0, 4)), () => setApps([])); }, [binary, file.path]);
const act = (work: () => Promise<unknown>) => () => void work().catch(error => setLoad({ state: 'error', message: cleanIpcError(error) }));
return <>
<p className="artifact-viewer-note">Read-only copy from the server · {kb(file.size)}{load.state === 'ready' && load.preview.truncated ? ' · showing the first 2 MB' : ''}</p>
{load.state === 'loading' && <div role="status" className="artifact-viewer-status">Loading…</div>}
{load.state === 'error' && <div role="alert" className="artifact-viewer-status">{load.message}</div>}
{load.state === 'ready' && load.preview.kind === 'text' && <pre className="artifact-viewer-body" tabIndex={0} aria-label="File contents">{load.preview.text}</pre>}
{load.state === 'ready' && load.preview.kind === 'image' && <img className="server-output-image" src={load.preview.dataUrl} alt={file.name}/>}
{binary && <div className="artifact-viewer-status">Muster cannot preview this type of file here.
<div className="work-inline-form">
<button type="button" className="settings-button secondary" onClick={act(() => invoke('files.external.reveal', { path: file.path }))}><FolderOpen size={13}/>Reveal in Finder</button>
{apps.map(app => <button key={app.id} type="button" className="settings-button secondary" onClick={act(() => invoke('files.external.openWith', { path: file.path, app: app.id }))}>Open with {app.name}</button>)}
</div></div>}
</>;
}

export function ServerOutputViewer({ title, file, serverUrl, projectId, outputId, onClose }: { title: string; file: ServerOutputFile; serverUrl: string | null; projectId: string; outputId: string; onClose: () => void }): React.ReactElement {
const [note, setNote] = useState('');
return <Dialog.Root open onOpenChange={next => { if (!next) onClose(); }}>
<Dialog.Portal>
<Dialog.Backdrop className="file-dialog-backdrop"/>
<Dialog.Popup className="file-dialog artifact-viewer" aria-label={`${title} (read-only copy from the server)`}>
<div className="file-dialog-heading">
<Dialog.Title className="artifact-viewer-title"><FileText size={14} aria-hidden="true"/><span title={title}>{title}</span></Dialog.Title>
<span className="server-output-actions">
<button type="button" className="icon-button" aria-label={`Download ${title}`} title="Download…" onClick={() => void invoke('paperclip.output.save', { id: outputId, projectId }).then(r => setNote(r.saved ? `Saved ${r.fileName}.` : ''), e => setNote(cleanIpcError(e)))}><Download size={14}/></button>
{serverUrl && <button type="button" className="icon-button" aria-label={`Open ${title} on server`} title="Open on server" onClick={() => void invoke('link.open', { url: serverUrl }).catch(() => undefined)}><ExternalLink size={14}/></button>}
<Dialog.Close className="artifact-viewer-close" aria-label="Close"><X size={14}/></Dialog.Close>
</span>
</div>
{note && <div role="status" className="artifact-viewer-status">{note}</div>}
<ServerOutputContents file={file}/>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>;
}
31 changes: 28 additions & 3 deletions packages/agent-app/src/renderer/components/WorkOutputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
* merged), approve / request changes (which tells the task's owner), an arrival cue for what is new since you last looked,
* and previews through the app's own file viewers and pull request tab.
*/
import { Box, Check, ExternalLink, FileText, GitPullRequest, Image, Search, Table2, Video, Code2, Type } from 'lucide-react';
import { Box, Check, Download, ExternalLink, FileText, GitPullRequest, Image, Search, Table2, Video, Code2, Type } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { WorkspaceRow, WorkspaceSnapshot } from '../../shared/domains/paperclip-protocol';
import { OUTPUT_KINDS, OUTPUT_STATUS_LABEL, OUTPUT_STATUSES, type OutputKind, type OutputStatus } from '../../shared/domains/work-protocol';
import { filterOutputs, outputItems, type OutputItem } from '../outputsModel';
import { invoke } from '../bridge';
import { agoLabel, exactTime } from '../relativeTime';
import { closeSettings, notifyError, notifySuccess, openFile, openPullRequestTab } from '../store';
import type { ServerOutputFile } from '../../shared/domains/paperclip-protocol';
import { cleanIpcError } from './resourceErrors';
import { ServerOutputViewer } from './ServerOutputViewer';
import { useStore } from '../useStore';
import { useWorkLoad } from '../workHooks';
import { StateChip } from './HubParts';
Expand Down Expand Up @@ -49,7 +52,25 @@ export function OutputsPanel({ snapshot, projectId, local, nav }: { snapshot: Wo
try { await invoke('work.outputs.status', { projectId, outputId: i.id, status: next, note: why, taskId: i.taskId, title: i.title }); setAsking(null); setNote(''); meta.reload(); if (next === 'changes_requested') notifySuccess('Sent. The owner has your note.'); }
catch (cause) { notifyError(cause); } finally { setBusy(null); }
};
// A server output has no folder here: Muster fetches it (or finds the same file in the Work locally folder) and shows it.
const [loading, setLoading] = useState<string | null>(null), [failed, setFailed] = useState<Record<string, string>>({}), [viewing, setViewing] = useState<{ item: OutputItem; file: ServerOutputFile } | null>(null);
const openServer = async (i: OutputItem) => {
if (loading) return;
setLoading(i.id); setFailed(f => { const { [i.id]: _gone, ...rest } = f; return rest; });
try {
const file = await invoke('paperclip.output.fetch', { id: i.id, projectId });
if (file.kind === 'link' && file.url) { void invoke('link.open', { url: file.url }).catch(() => { window.open(file.url!, '_blank', 'noopener'); }); return; }
if (file.kind === 'local' && file.folderPath && file.relPath) {
const folder = app?.folders.find(f => f.path === file.folderPath) ?? await invoke('folder.add', { path: file.folderPath });
void openFile(folder.id, file.relPath); closeSettings(); return;
}
setViewing({ item: i, file });
} catch (cause) { setFailed(f => ({ ...f, [i.id]: cleanIpcError(cause) })); }
finally { setLoading(null); }
};
const download = (i: OutputItem) => void invoke('paperclip.output.save', { id: i.id, projectId }).then(r => { if (r.saved) notifySuccess(`Saved “${r.fileName}”.`); }, cause => setFailed(f => ({ ...f, [i.id]: cleanIpcError(cause) })));
const open = (i: OutputItem) => {
if (i.output) { void openServer(i); return; }
if (i.kind === 'pull_request' && i.url) { void invoke('link.open', { url: i.url }).catch(() => { window.open(i.url!, '_blank', 'noopener'); }); return; }
if (i.path && primaryFolder) { void openFile(primaryFolder, i.path); closeSettings(); }
};
Expand Down Expand Up @@ -83,11 +104,11 @@ export function OutputsPanel({ snapshot, projectId, local, nav }: { snapshot: Wo
: groups.map(g => <div key={g.label || 'all'} className="work-output-group">
{g.label && <h3 className="ws-group-title">{g.label}<span>{g.rows.length}</span></h3>}
<ul className="ws-rows" aria-label={g.label || 'Outputs'}>{g.rows.map(i => {
const Icon = ICON[i.kind], st = states[i.id], task = i.taskId ? tasks.get(i.taskId) : undefined, can = Boolean(i.path && primaryFolder) || Boolean(i.url);
const Icon = ICON[i.kind], st = states[i.id], task = i.taskId ? tasks.get(i.taskId) : undefined, can = Boolean(i.output) || Boolean(i.path && primaryFolder) || Boolean(i.url);
return <li key={i.id} className="work-output" data-new={isNew(i) || undefined} data-kind={i.kind}>
<div className="ws-row is-static">
<Icon size={15} aria-hidden={true}/>
<span className="ws-row-text"><span className="ws-row-title">{can ? <button type="button" className="work-output-open" onClick={() => open(i)}>{i.title}</button> : i.title}{isNew(i) && <span className="ws-chip" data-tone="accent">New</span>}</span>
<span className="ws-row-text"><span className="ws-row-title">{can ? <button type="button" className="work-output-open" onClick={() => open(i)}>{i.title}</button> : i.title}{isNew(i) && <span className="ws-chip" data-tone="accent">New</span>}{loading === i.id && <span className="work-output-spinner" role="status" aria-label={`Opening ${i.title}`}/>}</span>
<span className="ws-row-meta">{i.detail}</span></span>
{task && group !== 'task' && <button type="button" className="ws-chip-link" onClick={() => nav.onOpenTask(task.id)} title={task.title}>{task.key}</button>}
{local && (st ? <StateChip tone={STATUS_TONE[st.status]}>{OUTPUT_STATUS_LABEL[st.status]}</StateChip> : null)}
Expand All @@ -96,9 +117,12 @@ export function OutputsPanel({ snapshot, projectId, local, nav }: { snapshot: Wo
<button type="button" className="settings-button" disabled={busy === i.id} onClick={() => void setState(i, 'approved')}><Check size={13}/>Approve</button>
<button type="button" className="settings-button secondary" disabled={busy === i.id} onClick={() => { setAsking(i.id); setNote(''); }}>Request changes</button></>}
{i.kind === 'pull_request' && i.prNumber && i.repo && primaryFolder && <Tip label="Open in the pull request tab"><button type="button" className="icon-button" aria-label={`Open ${i.repo}#${i.prNumber} in Muster`} onClick={() => { openPullRequestTab(primaryFolder, i.prNumber!, i.title); closeSettings(); }}><GitPullRequest size={13}/></button></Tip>}
{i.output?.downloadable && <Tip label="Download…"><button type="button" className="icon-button" aria-label={`Download ${i.title}`} onClick={() => download(i)}><Download size={13}/></button></Tip>}
{i.output && <Tip label="Open on server"><button type="button" className="icon-button" aria-label={`Open ${i.title} on server`} onClick={() => void invoke('link.open', { url: i.output!.href }).catch(() => { window.open(i.output!.href, '_blank', 'noopener'); })}><ExternalLink size={13}/></button></Tip>}
{i.url && <Tip label="Open on GitHub"><button type="button" className="icon-button" aria-label={`Open ${i.title} on GitHub`} onClick={() => open(i)}><ExternalLink size={13}/></button></Tip>}
{i.at && <span className="ws-row-age" title={exactTime(i.at)}>{agoLabel(i.at)}</span>}
</div>
{failed[i.id] && <p className="work-output-error" role="alert">{failed[i.id]}</p>}
{st?.status === 'changes_requested' && st.note && <p className="work-output-note"><strong>Changes requested:</strong> {st.note}</p>}
{asking === i.id && <form className="work-comment-form" onSubmit={e => { e.preventDefault(); void setState(i, 'changes_requested', note.trim()); }}>
<textarea className="work-comment-text" aria-label={`What should change in ${i.title}?`} rows={2} placeholder="What should change? The owner receives this note." value={note} onChange={e => setNote(e.target.value)} autoFocus/>
Expand All @@ -107,6 +131,7 @@ export function OutputsPanel({ snapshot, projectId, local, nav }: { snapshot: Wo
</li>;
})}</ul>
</div>)}
{viewing && <ServerOutputViewer title={viewing.item.title} file={viewing.file} serverUrl={viewing.item.output?.href ?? null} projectId={projectId} outputId={viewing.item.id} onClose={() => setViewing(null)}/>}
{shown.length > CAP && !more && <button type="button" className="ws-link" onClick={() => setMore(true)}>Show {shown.length - CAP} more</button>}
</section>;
}
Loading
Loading