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
Binary file added docs/images/paper-writing-workbench.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

28 changes: 27 additions & 1 deletion src/apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,33 @@ agent to draft or revise files in your workspace. Open the main `.tex` file
from the file tree or its chat link. The editor shows source on the left and the
typeset PDF on the right. Use **Save & compile** or turn on **Auto compile**
to save and recompile the main source after you pause. A failed build keeps the
last successful preview and shows the compiler log.
last successful preview and shows the compiler log. The source and PDF panes can
be closed independently, restored from the header, and resized by dragging their
divider. Hidden source views retain the document and its undo history.

The source toolbar includes undo/redo, find/replace, a section outline, and
snippets for equations, figures, tables, and references. Command, environment,
and in-document label completions are available in Monaco. Select the main TeX
entry when editing an included chapter, or add a `% !TeX root = ../main.tex`
comment. Compiler diagnostics for the current file link back to their source line.

PDF review supports text highlights, sticky notes, comment threads, freehand ink,
annotation colors, resolution, deletion, and undo/redo. Reviews are automatically
saved beside the source as `<source>.openbitfun-review.json`; include that file
when moving or sharing the project. **Save PDF** writes an editable
`<paper>.annotated.pdf` to the workspace. **Export annotated PDF** saves a copy
on the viewing device. Saved PDFs embed their review state so notes and threads
can be edited again when reopened in OpenBitFun; other PDF applications see
standard PDF annotations. Replacing an existing output checks its current
version and asks before overwriting an externally changed file.

Annotations retain PDF page coordinates across compilation. Check their positions
when a revision changes the layout; the preview shows a reminder after recompiling.
LaTeX source remains the place to change manuscript text and figures.

![LaTeX source, PDF preview, and review notes in a sample writing workspace](../../../docs/images/paper-writing-workbench.png)

The image uses a sample paper in an isolated UI fixture.

Install `latexmk` and the selected pdfLaTeX or XeLaTeX engine on the machine that
owns the workspace. For SSH, Docker, and WSL workspaces, compile in that
Expand Down
31 changes: 21 additions & 10 deletions src/apps/desktop/src/api/latex_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ pub struct CompileWorkspaceLatexRequest {
#[serde(rename_all = "camelCase")]
pub struct CompileWorkspaceLatexResponse {
pdf_base64: String,
log: String,
}

#[derive(Debug)]
struct CompiledLatex {
pdf: Vec<u8>,
log: String,
}

#[tauri::command]
Expand All @@ -63,15 +70,16 @@ pub async fn compile_workspace_latex(
let workspace = WorkspaceBinding::resolve(&request.workspace_id)
.await
.map_err(|error| error.to_string())?;
let pdf_base64 = if workspace.is_remote() {
let (pdf_base64, log) = if workspace.is_remote() {
let connection_id = workspace
.connection_id()
.ok_or("Remote workspace is disconnected.")?;
compile_remote(connection_id, &workspace.root_path_string(), &request).await?
} else {
BASE64.encode(compile_local(workspace.root_path(), &request).await?)
let output = compile_local(workspace.root_path(), &request).await?;
(BASE64.encode(output.pdf), output.log)
};
Ok(CompileWorkspaceLatexResponse { pdf_base64 })
Ok(CompileWorkspaceLatexResponse { pdf_base64, log })
}

// latexmk invokes TeX and bibliography programs via their command templates.
Expand All @@ -93,7 +101,7 @@ fn source_stem(file_name: &str) -> Result<&str, String> {
async fn compile_local(
root: &Path,
request: &CompileWorkspaceLatexRequest,
) -> Result<Vec<u8>, String> {
) -> Result<CompiledLatex, String> {
let source = root.join(&request.file_path);
let root = tokio::fs::canonicalize(root)
.await
Expand Down Expand Up @@ -156,14 +164,14 @@ async fn compile_local(
.await
.map_err(|_| "LaTeX compilation timed out.")?
.map_err(|error| error.to_string())?;
let log = format!("{stdout}\n{stderr}");
if !status.success() {
let log = format!("{stdout}\n{stderr}");
return Err(format!("latexmk exited with {status}.\n{}", log.trim()));
}
drop(child);

let pdf = build.path().join(format!("{stem}.pdf"));
tokio::task::spawn_blocking(move || {
let pdf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let _build = build;
let metadata = std::fs::symlink_metadata(&pdf).map_err(|_| MISSING_PDF)?;
if !metadata.is_file() || is_symlink_or_reparse(&metadata) {
Expand All @@ -176,7 +184,8 @@ async fn compile_local(
}
})
.await
.map_err(|error| error.to_string())?
.map_err(|error| error.to_string())??;
Ok(CompiledLatex { pdf, log })
}

async fn read_log_tail(mut reader: impl AsyncRead + Unpin) -> std::io::Result<String> {
Expand Down Expand Up @@ -229,6 +238,7 @@ if latexmk {arguments} "-outdir=$build" "$source" </dev/null >"$build/compile.lo
pdf="$build/"{pdf}
[ -f "$pdf" ] && [ ! -L "$pdf" ] || {{ echo "{MISSING_PDF}" >&2; exit 1; }}
[ "$(wc -c < "$pdf")" -le {MAX_PDF_BYTES} ] || {{ echo "The PDF exceeds the 8 MiB preview limit." >&2; exit 1; }}
tail -c {MAX_LOG_BYTES} "$build/compile.log" >&2
base64 < "$pdf"
else
tail -c {MAX_LOG_BYTES} "$build/compile.log" >&2
Expand All @@ -247,7 +257,7 @@ async fn compile_remote(
connection_id: &str,
root: &str,
request: &CompileWorkspaceLatexRequest,
) -> Result<String, String> {
) -> Result<(String, String), String> {
let command = remote_build_command(root, &request.file_path, request.engine)?;
let manager = get_remote_workspace_manager().ok_or("Remote workspace is unavailable.")?;
let ssh = manager
Expand Down Expand Up @@ -288,7 +298,7 @@ async fn compile_remote(
if pdf.len() > MAX_PDF_BYTES || !pdf.starts_with(b"%PDF-") {
return Err(MISSING_PDF.into());
}
Ok(encoded)
Ok((encoded, result.stderr))
}

#[cfg(test)]
Expand Down Expand Up @@ -353,7 +363,8 @@ mod tests {
engine: LatexEngine::PdfLatex,
};
let pdf = compile_local(root.path(), &request).await.unwrap();
assert!(pdf.starts_with(b"%PDF-"));
assert!(pdf.pdf.starts_with(b"%PDF-"));
assert!(pdf.log.contains("Latexmk"));
assert!(!root.path().join("main draft.aux").exists());
assert_eq!(
std::fs::read_to_string(previous_pdf).unwrap(),
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"parse-entities": "^4.0.2",
"partial-json": "^0.1.7",
"path-browserify": "^1.0.1",
"pdf-lib": "1.17.1",
"pdfjs-dist": "5.4.624",
"prismjs": "^1.30.0",
"qrcode.react": "^4.2.0",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const ImageViewer = lazyWithRecovery(() =>
);

const PdfViewer = lazyWithRecovery(() =>
import('@/tools/editor/components/PdfViewer').then(module => ({
import('@/tools/editor/pdf-review/PdfReviewEditor').then(module => ({
default: module.default,
}))
);
Expand Down Expand Up @@ -417,6 +417,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
isActiveTab={isActive}
filePath={pdfViewerData.filePath || ''}
fileName={content.title}
onDirtyStateChange={onDirtyStateChange}
className="openbitfun-flexible-panel__pdf-viewer"
/>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/app/workbench/ContentResourceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export default function ContentResourceView({ resourceId, isActive }: { resource
cancelText: t('workbench.cancel'), type: 'warning' });
if (choice === 'cancel') return false;
if (choice === 'secondary') return true;
if (!document?.save || latest.scope.surfaceId !== getActiveSurfaceId()) return false;
await document.save();
if (!document || latest.scope.surfaceId !== getActiveSurfaceId()) return false;
await document.saveAll();
return !useContentResourceStore.getState().resources[resourceId]?.isDirty;
}), [document, resourceId, t]);
useTabCloseShortcut('workbench-content',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const peerModeMock = vi.hoisted(() => ({ active: true }));
const stateMachineMock = vi.hoisted(() => ({
getOrCreate: vi.fn(),
get: vi.fn(() => ({
getCurrentState: () => 'idle',
getContext: () => ({ lastUpdateTime: 0 }),
Expand All @@ -20,9 +21,11 @@ vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({

vi.mock('../../state-machine', () => ({
stateMachineManager: stateMachineMock,
SessionExecutionEvent: { START: 'start' },
}));

vi.mock('../liveSessionInteractionStore', () => ({
vi.mock('../liveSessionInteractionStore', async importOriginal => ({
...await importOriginal<typeof import('../liveSessionInteractionStore')>(),
installLiveSessionInteractionMailbox: vi.fn(),
}));

Expand All @@ -42,9 +45,15 @@ import {
runtimeProjectionCaughtUp,
} from './PeerSessionRefreshModule';
import { processToolEvent } from './ToolEventModule';
import { processNormalTextChunkInternal } from './TextChunkModule';
import { convertDialogTurnToBackendFormat } from './PersistenceModule';
import { resetDeviceSurfaceSwitchedForTest } from '@/infrastructure/peer-device/deviceSurfaceReconcile';
import {
isRuntimeSessionProjectionStale,
markRuntimeSessionProjectionStale,
readRuntimeSessionProgress,
resetRuntimeSessionEventGateForTest,
routeRuntimeSessionEvent,
} from '@/infrastructure/peer-device/runtimeSessionEventGate';

describe('PeerSessionRefreshModule', () => {
Expand Down Expand Up @@ -1067,6 +1076,8 @@ describe('PeerSessionRefreshModule journal apply', () => {
vi.useFakeTimers();
peerModeMock.active = true;
resetRuntimeSessionEventGateForTest();
resetDeviceSurfaceSwitchedForTest();
activateSurface(LOCAL_SURFACE_ID);
stateMachineMock.get.mockReturnValue({
getCurrentState: () => 'processing',
getContext: () => ({ lastUpdateTime: Date.now(), version: 0 }),
Expand Down Expand Up @@ -1094,10 +1105,160 @@ describe('PeerSessionRefreshModule journal apply', () => {
activeSessionId: null,
}));
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});

it.each([true, false])('replays a pristine local active restore when refresh is installed before hydrate: %s', async installBeforeHydrate => {
peerModeMock.active = false;
const store = FlowChatStore.getInstance();
const sessionId = 'local-reload';
const turnId = 'turn-live';
const roundId = 'round-live';
store.setState(prev => ({
...prev,
activeSessionId: sessionId,
sessions: new Map([[sessionId, {
sessionId,
title: 'Local reload',
workspaceId: 'workspace-1',
workspacePath: '/repo/OpenBitFun',
historyState: 'metadata-only',
isHistorical: true,
isTransient: false,
dialogTurns: [],
config: { agentType: 'PaperWriting' },
createdAt: 1,
lastActiveAt: 1,
todos: [],
} as any]]),
}));
const context = {
flowChatStore: store,
eventBatcher: { flushNow: vi.fn(), clear: vi.fn(), getBufferSize: () => 0 },
contentBuffers: new Map(),
activeTextItems: new Map(),
runtimeStatusTimers: new Map(),
saveDebouncers: new Map(),
lastSaveTimestamps: new Map(),
lastSaveHashes: new Map(),
turnSaveInFlight: new Map(),
turnSavePending: new Set(),
} as any;
const apply = (eventName: string, payload: any): boolean => {
if (eventName === 'agentic://model-round-started') {
store.addModelRound(sessionId, turnId, {
id: roundId, index: 0, items: [], status: 'streaming',
isStreaming: true, isComplete: false, startTime: 1,
});
} else if (eventName === 'agentic://text-chunk') {
processNormalTextChunkInternal(context, sessionId, turnId, roundId, payload.text);
} else if (eventName === 'agentic://tool-event') {
processToolEvent(context, sessionId, turnId, roundId, payload.toolEvent);
}
return true;
};
agenticListenerMock.dispatchExternal.mockImplementation(apply);
const events = [
{ eventName: 'agentic://dialog-turn-started', payload: { sessionId, turnId } },
{ eventName: 'agentic://model-round-started', payload: { sessionId, turnId, roundId } },
{ eventName: 'agentic://text-chunk', payload: { sessionId, turnId, roundId, text: 'Hello' } },
{ eventName: 'agentic://tool-event', payload: { sessionId, turnId, roundId, toolEvent: {
event_type: 'Started', tool_id: 'read-1', tool_name: 'Read', params: { file_path: 'main.tex' },
} } },
{ eventName: 'agentic://tool-event', payload: { sessionId, turnId, roundId, toolEvent: {
event_type: 'Completed', tool_id: 'read-1', tool_name: 'Read', result: 'source',
} } },
];
const view = {
session: { sessionId, agentType: 'PaperWriting', state: 'Processing', createdAt: 1, turnCount: 7 },
turns: [{
turnId, turnIndex: 6, sessionId, timestamp: 1,
userMessage: { id: 'user-live', content: 'Open LaTeX', timestamp: 1 },
modelRounds: [], startTime: 1, status: 'inprogress',
}],
contextRestoreState: 'pending', isPartial: true, loadedTurnCount: 1, totalTurnCount: 7,
turnCatalog: { schemaVersion: 1, sessionId, revision: 'catalog-1', complete: true,
totalTurnCount: 7, entries: [] },
runtimeEventSnapshot: { sessionId, streamId: 'runtime-local', cursor: 5, activeTurnId: turnId, events },
} as any;
let resolveRefresh!: (value: any) => void;
const restore = vi.spyOn(agentAPI, 'restoreSessionView')
.mockResolvedValueOnce(view)
.mockImplementationOnce(() => new Promise(resolve => { resolveRefresh = resolve; }));
let cleanup: (() => void) | undefined;
try {
if (installBeforeHydrate) cleanup = installPeerSessionRefresh(context);
await store.loadSessionHistory(sessionId);
expect(store.getState().sessions.get(sessionId)?.dialogTurns[0].modelRounds).toEqual([]);
expect(isRuntimeSessionProjectionStale(LOCAL_SURFACE_ID, sessionId)).toBe(true);
if (!installBeforeHydrate) cleanup = installPeerSessionRefresh(context);
await vi.advanceTimersByTimeAsync(1);
expect(restore).toHaveBeenCalledTimes(2);

// Live delivery races the host read: the covered prefix must not repeat,
// and the suffix must wait for the journal's model round to exist.
const deliver = (cursor: number, text: string) => routeRuntimeSessionEvent(
LOCAL_SURFACE_ID, 'agentic://text-chunk', {
sessionId, turnId, roundId, text,
__openbitfunRuntimeStreamId: 'runtime-local', __openbitfunRuntimeEventCursor: cursor,
}, payload => { apply('agentic://text-chunk', payload); },
);
deliver(3, 'Hello');
deliver(6, ' world');
expect(agenticListenerMock.dispatchExternal).not.toHaveBeenCalled();
resolveRefresh(view);
await vi.advanceTimersByTimeAsync(1);

const turn = store.getState().sessions.get(sessionId)!.dialogTurns[0];
expect(turn.modelRounds).toHaveLength(1);
expect(turn.modelRounds[0].items.find(item => item.type === 'text')).toMatchObject({ content: 'Hello world' });
expect(store.findToolItem(sessionId, turnId, 'read-1')?.status).toBe('completed');
expect(convertDialogTurnToBackendFormat(turn).modelRounds).toHaveLength(1);
expect(readRuntimeSessionProgress(LOCAL_SURFACE_ID, sessionId)).toEqual({ streamId: 'runtime-local', cursor: 6 });
expect(isRuntimeSessionProjectionStale(LOCAL_SURFACE_ID, sessionId)).toBe(false);
await vi.advanceTimersByTimeAsync(60_000);
expect(restore).toHaveBeenCalledTimes(2);
} finally {
cleanup?.();
}
});

it.each(['Idle', 'Processing'])('keeps pristine local persisted content without extra reads when a %s host has no journal', async state => {
peerModeMock.active = false;
const store = FlowChatStore.getInstance();
const sessionId = 'local-legacy';
store.setState(prev => ({ ...prev, activeSessionId: sessionId, sessions: new Map([[sessionId, {
sessionId, title: 'Local', workspaceId: 'workspace-1', workspacePath: '/repo/OpenBitFun',
historyState: 'metadata-only', isHistorical: true, isTransient: false,
dialogTurns: [], config: { agentType: 'PaperWriting' }, createdAt: 1, lastActiveAt: 1, todos: [],
} as any]]) }));
const restore = vi.spyOn(agentAPI, 'restoreSessionView').mockResolvedValue({
session: { sessionId, state, agentType: 'PaperWriting', createdAt: 1 },
turns: [{
turnId: 'turn-legacy', turnIndex: 0, sessionId, timestamp: 1,
userMessage: { id: 'user-legacy', content: 'Open LaTeX', timestamp: 1 },
modelRounds: [{ id: 'round-legacy', roundIndex: 0, startTime: 1, status: 'streaming',
textItems: [{ id: 'text-legacy', content: 'Persisted reply', timestamp: 1 }], toolItems: [], thinkingItems: [] }],
startTime: 1, status: state === 'Idle' ? 'completed' : 'inprogress',
}], contextRestoreState: 'ready',
} as any);
const cleanup = installPeerSessionRefresh({
flowChatStore: store, eventBatcher: { flushNow: vi.fn(), clear: vi.fn() },
contentBuffers: new Map(), activeTextItems: new Map(),
} as any);
try {
await store.loadSessionHistory(sessionId);
await vi.advanceTimersByTimeAsync(60_000);
expect(store.getState().sessions.get(sessionId)?.dialogTurns[0].modelRounds[0].items[0]).toMatchObject({ content: 'Persisted reply' });
expect(restore).toHaveBeenCalledTimes(1);
expect(agenticListenerMock.dispatchExternal).not.toHaveBeenCalled();
} finally {
cleanup();
}
});

it('replays a cursor-covered ToolEnd until the card is completed', async () => {
const store = FlowChatStore.getInstance();
store.setState(prev => ({
Expand Down
Loading
Loading