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
34 changes: 33 additions & 1 deletion src/apps/desktop/src/api/path_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ async fn lookup_remote_entry_for_path(
path: &str,
request_preferred: Option<&str>,
) -> Option<RemoteWorkspaceEntry> {
if should_force_local_assistant_path(path, request_preferred) {
return None;
}

let manager = get_remote_workspace_manager()?;
let legacy = app_state
.get_remote_workspace_async()
Expand All @@ -126,6 +130,14 @@ async fn lookup_remote_entry_for_path(
manager.lookup_connection(path, preferred.as_deref()).await
}

fn should_force_local_assistant_path(
path: &str,
explicit_remote_connection_id: Option<&str>,
) -> bool {
explicit_remote_connection_id.is_none()
&& get_path_manager_arc().is_local_assistant_workspace_path(path)
}

pub async fn resolve_desktop_path_target(
app_state: &AppState,
raw_path: &str,
Expand Down Expand Up @@ -289,7 +301,9 @@ fn encode_remote_file_bytes(bytes: Vec<u8>, encoding: Option<&str>) -> Result<St

#[cfg(test)]
mod tests {
use super::encode_remote_file_bytes;
use super::{
encode_remote_file_bytes, get_path_manager_arc, should_force_local_assistant_path,
};

#[test]
fn remote_file_bytes_support_explicit_base64_encoding() {
Expand All @@ -308,6 +322,24 @@ mod tests {
);
assert!(encode_remote_file_bytes(vec![0xff], None).is_err());
}

#[test]
fn local_assistant_path_ignores_legacy_remote_fallback_without_explicit_hint() {
let assistant_path = get_path_manager_arc()
.assistant_workspace_dir("path-target-local-save", None)
.to_string_lossy()
.to_string();

assert!(should_force_local_assistant_path(&assistant_path, None));
assert!(!should_force_local_assistant_path(
&assistant_path,
Some("explicit-remote-connection")
));
assert!(!should_force_local_assistant_path(
"/tmp/regular-project",
None
));
}
}

pub async fn write_text_file(
Expand Down
Binary file added src/web-ui/public/panda_wink.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment jsdom

import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
useAgentIdentityDocument,
type UseAgentIdentityDocumentResult,
} from './useAgentIdentityDocument';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

const apiMocks = vi.hoisted(() => ({
readFileContent: vi.fn(),
writeFileContent: vi.fn(),
}));
const watchFileChangesMock = vi.hoisted(() => vi.fn(() => vi.fn()));

vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({
workspaceAPI: {
readFileContent: apiMocks.readFileContent,
writeFileContent: apiMocks.writeFileContent,
},
}));

vi.mock('@/tools/file-system/services/FileSystemService', () => ({
fileSystemService: {
watchFileChanges: watchFileChangesMock,
},
}));

vi.mock('@/shared/services/ide-control', () => ({
ideControl: {
navigation: {
goToFile: vi.fn(),
},
},
}));

vi.mock('@/shared/utils/logger', () => ({
createLogger: () => ({
error: vi.fn(),
}),
}));

const INITIAL_IDENTITY = [
'---',
'name: Mira',
'creature: Assistant',
'vibe: Focused',
'emoji: 💼',
'---',
'',
].join('\n');

describe('useAgentIdentityDocument autosave', () => {
let container: HTMLDivElement;
let root: Root;
let latestResult: UseAgentIdentityDocumentResult | null;

const Harness = () => {
latestResult = useAgentIdentityDocument('/tmp/assistant');
return null;
};

beforeEach(async () => {
vi.useFakeTimers();
apiMocks.readFileContent.mockReset();
apiMocks.writeFileContent.mockReset();
watchFileChangesMock.mockClear();
apiMocks.readFileContent.mockResolvedValue(INITIAL_IDENTITY);
apiMocks.writeFileContent
.mockRejectedValueOnce(new Error('write failed'))
.mockResolvedValue(undefined);

latestResult = null;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root.render(<Harness />);
await Promise.resolve();
});
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});

it('stops retrying after a failed write until the user edits again', async () => {
act(() => latestResult?.updateField('emoji', '🚀'));

await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});

expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(1);
expect(latestResult?.saveStatus).toBe('error');

await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
});
expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(1);

act(() => latestResult?.updateField('emoji', '🧭'));
expect(latestResult?.saveStatus).toBe('idle');

await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(2);
expect(latestResult?.saveStatus).toBe('saved');
});
});
30 changes: 18 additions & 12 deletions src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,6 @@ export function useAgentIdentityDocument(

useEffect(() => {
if (!workspacePath || !identityFilePath || !hasUnsavedChanges) {
if (saveStatus === 'saved') {
const clearSavedStatus = setTimeout(() => {
if (mountedRef.current) {
setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus));
}
}, 1500);

return () => clearTimeout(clearSavedStatus);
}

return;
}

Expand All @@ -198,6 +188,7 @@ export function useAgentIdentityDocument(
}

saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
void saveDocument();
}, AUTOSAVE_DEBOUNCE_MS);

Expand All @@ -206,7 +197,21 @@ export function useAgentIdentityDocument(
clearTimeout(saveTimerRef.current);
}
};
}, [hasUnsavedChanges, identityFilePath, saveDocument, saveStatus, workspacePath]);
}, [hasUnsavedChanges, identityFilePath, saveDocument, workspacePath]);

useEffect(() => {
if (saveStatus !== 'saved') {
return;
}

const clearSavedStatus = setTimeout(() => {
if (mountedRef.current) {
setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus));
}
}, 1500);

return () => clearTimeout(clearSavedStatus);
}, [saveStatus]);

useEffect(() => {
if (!workspacePath || !identityFilePath) {
Expand Down Expand Up @@ -255,7 +260,8 @@ export function useAgentIdentityDocument(
const updateField = useCallback(
<K extends keyof IdentityDocument>(field: K, value: IdentityDocument[K]) => {
setDocument((previous) => ({ ...previous, [field]: value }));
if (saveStatus === 'external-update') {
if (saveStatus === 'external-update' || saveStatus === 'error') {
setError(null);
setSaveStatus('idle');
}
},
Expand Down
106 changes: 106 additions & 0 deletions src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// @vitest-environment jsdom

import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AssistantAvatarPicker from './AssistantAvatarPicker';
import { firstAvatarGrapheme } from './assistantAvatar';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

vi.mock('@/component-library', () => ({
Button: ({
children,
variant: _variant,
size: _size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string;
size?: string;
}) => <button {...props}>{children}</button>,
IconButton: ({
children,
variant: _variant,
size: _size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string;
size?: string;
}) => <button {...props}>{children}</button>,
Input: ({
size: _size,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
size?: string;
}) => <input {...props} />,
}));

describe('AssistantAvatarPicker', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

it('keeps a combined emoji sequence as one avatar', () => {
expect(firstAvatarGrapheme(' 🧑‍💻🚀 ')).toBe('🧑‍💻');
});

it('opens from the avatar and applies a preset through the shared change callback', () => {
const onChange = vi.fn();

act(() => {
root.render(
<AssistantAvatarPicker
value="💼"
saveStatus="idle"
onChange={onChange}
/>,
);
});

const trigger = container.querySelector('.acp-avatar-picker__trigger') as HTMLButtonElement;
act(() => trigger.click());

expect(trigger.getAttribute('aria-expanded')).toBe('true');
const compassOption = Array.from(
container.querySelectorAll<HTMLButtonElement>('.acp-avatar-picker__option'),
).find((option) => option.textContent === '🧭');
expect(compassOption).toBeTruthy();

act(() => compassOption?.click());
expect(onChange).toHaveBeenCalledWith('🧭');
});

it('announces the autosave result while the picker is open', () => {
act(() => {
root.render(
<AssistantAvatarPicker
value="🧭"
saveStatus="saved"
onChange={vi.fn()}
/>,
);
});

const trigger = container.querySelector('.acp-avatar-picker__trigger') as HTMLButtonElement;
act(() => trigger.click());

expect(container.querySelector('.acp-avatar-picker__status')?.textContent)
.toContain('identity.avatarSaved');
});
});
Loading