"message": "Invalid 'input[0].content': string too long. Expected a string with maximum length 10485760, but got a string with length 11952519 instead.",
    "type": "invalid_request_error",
    "param": "input[0].content",
    "code": "string_above_max_length"
  }
This commit is contained in:
Pedram Amini
2025-12-30 04:41:36 -06:00
parent 4d67cce6e1
commit f3499ab7f0
19 changed files with 153208 additions and 208 deletions

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -0,0 +1,51 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.37581,0.00107,0.39677,1.00000",
"display-p3:0.92931,0.00000,0.97255,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
},
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "figure-large.svg",
"name" : "figure-large",
"position" : {
"scale" : 1,
"translation-in-points" : [
-14,
-19
]
}
}
],
"name" : "images",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -0,0 +1,43 @@
{
"fill" : {
"solid" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "figure-large.svg",
"name" : "figure-large",
"position" : {
"scale" : 1,
"translation-in-points" : [
-14,
-19
]
}
},
{
"glass" : false,
"image-name" : "maestro-full-background.png",
"name" : "maestro-full-background"
}
],
"name" : "images",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:#606060;}
.st2{display:inline;}
.st3{fill:none;stroke:#5DB3F9;stroke-width:1.5;stroke-miterlimit:10;}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#5DB3F9;stroke-width:1.5;stroke-miterlimit:10;}
.st5{fill:url(#SVGID_1_);stroke:#000000;stroke-miterlimit:10;}
</style>
<g id="Background" class="st0">
</g>
<g id="_x31___x2013__Layer" class="st0">
</g>
<g id="_x32___x2013__Layer" class="st0">
</g>
<g id="_x33___x2013__Layer" class="st0">
</g>
<g id="_x34___x2013__Layer" class="st0">
</g>
<g id="App_Icon_Shape" class="st0">
<path class="st1" d="M0,0v1024h1024V0H0z M1024,651c0,14.24,0.01,28.48-0.08,42.73c-0.07,12-0.21,23.99-0.53,35.98
c-0.71,26.13-2.25,52.49-6.89,78.34c-4.71,26.22-12.4,50.62-24.53,74.44c-11.92,23.41-27.49,44.84-46.07,63.41
s-40,34.15-63.41,46.07c-23.82,12.12-48.22,19.82-74.44,24.53c-25.84,4.65-52.2,6.18-78.34,6.89c-11.99,0.33-23.99,0.46-35.98,0.53
c-14.24,0.09-28.48,0.08-42.73,0.08H373c-14.24,0-28.48,0.01-42.73-0.08c-12-0.07-23.99-0.21-35.98-0.53
c-26.13-0.71-52.49-2.25-78.34-6.89c-26.22-4.71-50.62-12.4-74.44-24.53c-23.41-11.92-44.84-27.49-63.41-46.07
s-34.15-40-46.07-63.41c-12.12-23.82-19.82-48.22-24.53-74.44c-4.65-25.84-6.18-52.2-6.89-78.34c-0.33-11.99-0.46-23.99-0.53-35.98
C-0.01,679.48,0,665.24,0,651V373c0-14.24-0.01-28.48,0.08-42.73c0.07-12,0.21-23.99,0.53-35.98c0.71-26.13,2.25-52.49,6.89-78.34
c4.71-26.22,12.4-50.62,24.53-74.44C43.95,118.1,59.53,96.68,78.1,78.1s40-34.15,63.41-46.07c23.82-12.12,48.22-19.82,74.44-24.53
c25.84-4.65,52.2-6.18,78.34-6.89c11.99-0.33,23.99-0.46,35.98-0.53C344.52-0.01,358.76,0,373,0h278c14.24,0,28.48-0.01,42.73,0.08
c12,0.07,23.99,0.21,35.98,0.53c26.13,0.71,52.49,2.25,78.34,6.89c26.22,4.71,50.62,12.4,74.44,24.53
c23.41,11.92,44.84,27.49,63.41,46.07s34.15,40,46.07,63.41c12.12,23.82,19.82,48.22,24.53,74.44c4.65,25.84,6.18,52.2,6.89,78.34
c0.33,11.99,0.46,23.99,0.53,35.98c0.09,14.24,0.08,28.48,0.08,42.73V651z"/>
</g>
<g id="Grid">
<g id="ROUNDED_RECTANGLE" class="st0">
</g>
<g id="SUBDIVISIONS" class="st0">
<g class="st2">
<line class="st3" x1="1024" y1="0" x2="0" y2="1024"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="1024" x2="0" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="512" y1="0" x2="512" y2="1024"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="512" x2="0" y2="512"/>
</g>
<g class="st2">
<line class="st3" x1="512" y1="0" x2="512" y2="1024"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="512" x2="0" y2="512"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="640" x2="0" y2="640"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="768" x2="0" y2="768"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="896" x2="0" y2="896"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="384" x2="0" y2="384"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="256" x2="0" y2="256"/>
</g>
<g class="st2">
<line class="st3" x1="1024" y1="128" x2="0" y2="128"/>
</g>
<g class="st2">
<line class="st3" x1="512" y1="1024" x2="512" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="512" y1="1024" x2="512" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="256" y1="1024" x2="256" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="128" y1="1024" x2="128" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="640" y1="1024" x2="640" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="768" y1="1024" x2="768" y2="0"/>
</g>
<g class="st2">
<line class="st3" x1="896" y1="1024" x2="896" y2="0"/>
</g>
</g>
<g id="CIRCLES">
<circle class="st4" cx="512" cy="512" r="410"/>
<circle class="st4" cx="512" cy="512" r="256"/>
<circle class="st4" cx="512" cy="512" r="102"/>
<radialGradient id="SVGID_1_" cx="512" cy="512.8904" r="512" gradientUnits="userSpaceOnUse">
<stop offset="0.3039" style="stop-color:#B4008C"/>
<stop offset="0.5057" style="stop-color:#A00B8D"/>
<stop offset="0.9185" style="stop-color:#6B2790"/>
<stop offset="1" style="stop-color:#602D91"/>
</radialGradient>
<rect x="0" y="0.89" class="st5" width="1024" height="1024"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_11" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
</style>
<radialGradient id="SVGID_1_" cx="512" cy="512" r="410" gradientUnits="userSpaceOnUse">
<stop offset="0.3228" style="stop-color:#B4008C;stop-opacity:0.5"/>
<stop offset="0.435" style="stop-color:#A4088D;stop-opacity:0.5828"/>
<stop offset="0.8129" style="stop-color:#732390;stop-opacity:0.8619"/>
<stop offset="1" style="stop-color:#602D91"/>
</radialGradient>
<path class="st0" d="M512,102c-226.44,0-410,183.56-410,410s183.56,410,410,410s410-183.56,410-410S738.44,102,512,102z M512,766.35
c-140.47,0-254.35-113.88-254.35-254.35c0-140.47,113.88-254.35,254.35-254.35S766.35,371.53,766.35,512
C766.35,652.47,652.47,766.35,512,766.35z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_11" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
</style>
<radialGradient id="SVGID_1_" cx="512" cy="512" r="254.3512" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#B4008C"/>
<stop offset="1" style="stop-color:#602D91"/>
</radialGradient>
<circle class="st0" cx="512" cy="512" r="254.35"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,43 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "figure-large 2.svg",
"name" : "figure-large 2"
},
{
"glass" : false,
"image-name" : "maestro-bckgrnd-3 2.svg",
"name" : "maestro-bckgrnd-3 2"
},
{
"glass" : false,
"image-name" : "maestro-bckgrnd-2 2.svg",
"name" : "maestro-bckgrnd-2 2"
},
{
"glass" : false,
"image-name" : "maestro-bckgrnd-1 2.svg",
"name" : "maestro-bckgrnd-1 2"
}
],
"name" : "images",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -0,0 +1,52 @@
{
"fill" : {
"linear-gradient" : [
"extended-gray:1.00000,1.00000",
"display-p3:0.90452,0.90132,0.89173,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
},
"groups" : [
{
"hidden" : false,
"layers" : [
{
"glass" : false,
"hidden" : false,
"image-name" : "Image.png",
"name" : "Image",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
-24,
-12.5
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -777,7 +777,7 @@ describe('SettingsModal', () => {
});
describe('General tab - History toggle', () => {
it('should call setDefaultSaveToHistory when checkbox is changed', async () => {
it('should call setDefaultSaveToHistory when toggle switch is changed', async () => {
const setDefaultSaveToHistory = vi.fn();
render(<SettingsModal {...createDefaultProps({ setDefaultSaveToHistory, defaultSaveToHistory: true })} />);
@@ -785,10 +785,13 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
const historyCheckbox = screen.getByText('Enable "History" by default for new tabs').closest('label')?.querySelector('input[type="checkbox"]');
expect(historyCheckbox).toBeDefined();
// SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"]
const titleElement = screen.getByText('Enable "History" by default for new tabs');
const toggleContainer = titleElement.closest('[role="button"]');
const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]');
expect(toggleSwitch).toBeDefined();
fireEvent.click(historyCheckbox!);
fireEvent.click(toggleSwitch!);
expect(setDefaultSaveToHistory).toHaveBeenCalledWith(false);
});
});
@@ -1031,7 +1034,7 @@ describe('SettingsModal', () => {
expect(screen.getByText('Enable OS Notifications')).toBeInTheDocument();
});
it('should call setOsNotificationsEnabled when checkbox is changed', async () => {
it('should call setOsNotificationsEnabled when toggle switch is changed', async () => {
const setOsNotificationsEnabled = vi.fn();
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', setOsNotificationsEnabled, osNotificationsEnabled: true })} />);
@@ -1039,13 +1042,16 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]');
fireEvent.click(checkbox!);
// SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"]
const titleElement = screen.getByText('Enable OS Notifications');
const toggleContainer = titleElement.closest('[role="button"]');
const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]');
fireEvent.click(toggleSwitch!);
expect(setOsNotificationsEnabled).toHaveBeenCalledWith(false);
});
it('should update checkbox state when prop changes (regression test for memo bug)', async () => {
it('should update toggle state when prop changes (regression test for memo bug)', async () => {
// This test ensures the component re-renders when props change
// A previous bug had an overly restrictive memo comparator that prevented re-renders
const { rerender } = render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', osNotificationsEnabled: true })} />);
@@ -1054,9 +1060,11 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
// Verify initial checked state
const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
// SettingCheckbox uses a button with role="switch" and aria-checked instead of input[type="checkbox"]
const titleElement = screen.getByText('Enable OS Notifications');
const toggleContainer = titleElement.closest('[role="button"]');
const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]') as HTMLButtonElement;
expect(toggleSwitch.getAttribute('aria-checked')).toBe('true');
// Rerender with changed prop (simulating what happens after onChange)
rerender(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', osNotificationsEnabled: false })} />);
@@ -1065,8 +1073,8 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(50);
});
// The checkbox should now be unchecked - this would fail with the old memo comparator
expect(checkbox.checked).toBe(false);
// The toggle should now be unchecked - this would fail with the old memo comparator
expect(toggleSwitch.getAttribute('aria-checked')).toBe('false');
});
it('should test notification when button is clicked', async () => {
@@ -1090,7 +1098,7 @@ describe('SettingsModal', () => {
expect(screen.getByText('Enable Audio Feedback')).toBeInTheDocument();
});
it('should call setAudioFeedbackEnabled when checkbox is changed', async () => {
it('should call setAudioFeedbackEnabled when toggle switch is changed', async () => {
const setAudioFeedbackEnabled = vi.fn();
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', setAudioFeedbackEnabled, audioFeedbackEnabled: false })} />);
@@ -1098,8 +1106,11 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
const checkbox = screen.getByText('Enable Audio Feedback').closest('label')?.querySelector('input[type="checkbox"]');
fireEvent.click(checkbox!);
// SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"]
const titleElement = screen.getByText('Enable Audio Feedback');
const toggleContainer = titleElement.closest('[role="button"]');
const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]');
fireEvent.click(toggleSwitch!);
expect(setAudioFeedbackEnabled).toHaveBeenCalledWith(true);
});

View File

@@ -535,6 +535,13 @@ describe('Usage Dashboard State Transition Animations', () => {
});
it('animations do not interfere with data refresh', async () => {
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockStats.onStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal
isOpen={true}
@@ -547,14 +554,13 @@ describe('Usage Dashboard State Transition Animations', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button
const refreshButton = screen.getByTitle('Refresh');
fireEvent.click(refreshButton);
// Data should still update
await waitFor(() => {
expect(mockStats.getAggregation).toHaveBeenCalledTimes(2); // Initial + refresh
// Trigger real-time update via the stats callback
act(() => {
if (statsCallback) statsCallback();
});
// Data should still update (callback was triggered, debounce will handle timing)
expect(mockStats.onStatsUpdate).toHaveBeenCalled();
});
it('animations apply correctly after modal reopen', async () => {

View File

@@ -674,10 +674,18 @@ describe('UsageDashboardModal', () => {
expect(mockOnStatsUpdate.mock.calls[0]).toBeDefined();
});
it('refresh button does not show loading spinner that hides data', async () => {
it('real-time updates do not show loading spinner that hides data', async () => {
const initialData = createSampleData();
const updatedData = { ...createSampleData(), totalQueries: 300 };
mockGetAggregation.mockResolvedValueOnce(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -688,22 +696,15 @@ describe('UsageDashboardModal', () => {
});
// Setup next fetch
mockGetAggregation.mockResolvedValueOnce(createSampleData());
mockGetAggregation.mockResolvedValueOnce(updatedData);
// Click the manual refresh button
const refreshButton = screen.getByTitle('Refresh');
fireEvent.click(refreshButton);
// Content should still be visible (refresh uses showRefresh=true path, not skeleton)
expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument();
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
// Wait for refresh to complete
await waitFor(() => {
expect(mockGetAggregation).toHaveBeenCalledTimes(2);
// Trigger real-time update via the stats callback
act(() => {
if (statsCallback) statsCallback();
});
// After refresh completes, content should still be visible
// Content should still be visible (real-time updates don't show skeleton)
expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument();
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
@@ -731,11 +732,18 @@ describe('UsageDashboardModal', () => {
expect(unsubscribeMock).toHaveBeenCalled();
});
it('content persists when refresh is triggered after initial load', async () => {
it('content persists when real-time update is triggered after initial load', async () => {
const initialData = createSampleData();
const refreshedData = { ...createSampleData(), totalQueries: 300 };
mockGetAggregation.mockResolvedValueOnce(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -748,17 +756,14 @@ describe('UsageDashboardModal', () => {
// Setup next fetch
mockGetAggregation.mockResolvedValueOnce(refreshedData);
// Click refresh
fireEvent.click(screen.getByTitle('Refresh'));
// Critical: content should NOT disappear during refresh (no skeleton shown)
expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument();
// Wait for update to complete
await waitFor(() => {
expect(mockGetAggregation).toHaveBeenCalledTimes(2);
// Trigger real-time update via the stats callback
act(() => {
if (statsCallback) statsCallback();
});
// Critical: content should NOT disappear during real-time update (no skeleton shown)
expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument();
// Verify content still there
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
@@ -810,6 +815,14 @@ describe('UsageDashboardModal', () => {
});
describe('New Data Visual Indicator', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('indicator does NOT appear for initial load', async () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
@@ -827,10 +840,17 @@ describe('UsageDashboardModal', () => {
expect(screen.queryByTestId('new-data-indicator')).not.toBeInTheDocument();
});
it('indicator appears after manual refresh completes', async () => {
it('indicator appears after real-time update completes', async () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -840,10 +860,13 @@ describe('UsageDashboardModal', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button
fireEvent.click(screen.getByTitle('Refresh'));
// Trigger real-time update via the stats callback and wait for debounce
await act(async () => {
if (statsCallback) statsCallback();
await vi.advanceTimersByTimeAsync(1100);
});
// Wait for indicator to appear after refresh completes
// Wait for indicator to appear after update completes
await waitFor(() => {
expect(screen.getByTestId('new-data-indicator')).toBeInTheDocument();
}, { timeout: 2000 });
@@ -856,6 +879,13 @@ describe('UsageDashboardModal', () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -865,8 +895,11 @@ describe('UsageDashboardModal', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button to trigger indicator
fireEvent.click(screen.getByTitle('Refresh'));
// Trigger real-time update via the stats callback and wait for debounce
await act(async () => {
if (statsCallback) statsCallback();
await vi.advanceTimersByTimeAsync(1100);
});
// Wait for indicator
await waitFor(() => {
@@ -880,6 +913,13 @@ describe('UsageDashboardModal', () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -889,8 +929,11 @@ describe('UsageDashboardModal', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button to trigger indicator
fireEvent.click(screen.getByTitle('Refresh'));
// Trigger real-time update via the stats callback and wait for debounce
await act(async () => {
if (statsCallback) statsCallback();
await vi.advanceTimersByTimeAsync(1100);
});
// Wait for indicator with pulsing dot
await waitFor(() => {
@@ -905,6 +948,13 @@ describe('UsageDashboardModal', () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -914,8 +964,11 @@ describe('UsageDashboardModal', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button to trigger indicator
fireEvent.click(screen.getByTitle('Refresh'));
// Trigger real-time update via the stats callback and wait for debounce
await act(async () => {
if (statsCallback) statsCallback();
await vi.advanceTimersByTimeAsync(1100);
});
// Wait for indicator and check theme styling
await waitFor(() => {
@@ -929,6 +982,13 @@ describe('UsageDashboardModal', () => {
const initialData = createSampleData();
mockGetAggregation.mockResolvedValue(initialData);
// Store the callback for triggering updates
let statsCallback: (() => void) | null = null;
mockOnStatsUpdate.mockImplementation((callback: () => void) => {
statsCallback = callback;
return vi.fn();
});
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -938,8 +998,11 @@ describe('UsageDashboardModal', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Click refresh button to trigger indicator
fireEvent.click(screen.getByTitle('Refresh'));
// Trigger real-time update via the stats callback and wait for debounce
await act(async () => {
if (statsCallback) statsCallback();
await vi.advanceTimersByTimeAsync(1100);
});
// Wait for indicator and check dot styling
await waitFor(() => {

View File

@@ -430,7 +430,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
}, [session.fullPath, session.changedFiles, session.fileExplorerExpanded, session.id, previewFile?.path, activeFocus, activeRightTab, selectedFileIndex, theme, toggleFolder, setSessions, setSelectedFileIndex, setActiveFocus, handleFileClick, fileTreeFilter, handleContextMenu]);
return (
<div className="space-y-2 relative">
<div className="flex flex-col h-full relative">
{/* File Tree Filter */}
{fileTreeFilterOpen && (
<div className="mb-3 pt-4">
@@ -650,7 +650,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
{/* Status bar at bottom */}
{session.fileTreeStats && (
<div
className="flex-shrink-0 flex items-center justify-center gap-3 px-3 py-1.5 text-xs rounded mt-2 mb-3"
className="flex-shrink-0 flex items-center justify-center gap-3 px-3 py-1.5 text-xs rounded mt-3 mb-[7px]"
style={{
backgroundColor: theme.colors.bgActivity,
border: `1px solid ${theme.colors.border}`,

View File

@@ -7,6 +7,7 @@ import { validateNewSession, validateEditSession } from '../utils/sessionValidat
import { FormInput } from './ui/FormInput';
import { Modal, ModalFooter } from './ui/Modal';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
import { SshRemoteSelector } from './shared/SshRemoteSelector';
// Maximum character length for nudge message
const NUDGE_MESSAGE_MAX_LENGTH = 1000;
@@ -575,15 +576,6 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
onRefreshAgent={() => handleRefreshAgent(agent.id)}
refreshingAgent={refreshingAgent === agent.id}
showBuiltInEnvVars
sshRemotes={sshRemotes}
sshRemoteConfig={agentSshRemoteConfigs[agent.id]}
onSshRemoteConfigChange={(config) => {
setAgentSshRemoteConfigs(prev => ({
...prev,
[agent.id]: config
}));
}}
globalDefaultSshRemoteId={globalDefaultSshRemoteId}
/>
</div>
)}
@@ -704,6 +696,22 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
</div>
)}
{/* SSH Remote Execution - Top Level */}
{sshRemotes.length > 0 && selectedAgent && (
<SshRemoteSelector
theme={theme}
sshRemotes={sshRemotes}
sshRemoteConfig={agentSshRemoteConfigs[selectedAgent]}
onSshRemoteConfigChange={(config) => {
setAgentSshRemoteConfigs(prev => ({
...prev,
[selectedAgent]: config
}));
}}
globalDefaultSshRemoteId={globalDefaultSshRemoteId}
/>
)}
{/* Nudge Message */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2" style={{ color: theme.colors.textMain }}>

View File

@@ -14,9 +14,8 @@
*/
import { useState, useRef, useMemo, useEffect } from 'react';
import { RefreshCw, Plus, Trash2, HelpCircle, ChevronDown, Monitor, Cloud } from 'lucide-react';
import { RefreshCw, Plus, Trash2, HelpCircle, ChevronDown } from 'lucide-react';
import type { Theme, AgentConfig, AgentConfigOption } from '../../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../shared/types';
// Counter for generating stable IDs for env vars
let envVarIdCounter = 0;
@@ -256,11 +255,6 @@ export interface AgentConfigPanelProps {
compact?: boolean;
// Show built-in environment variables section
showBuiltInEnvVars?: boolean;
// SSH Remote configuration (optional - only shown when provided)
sshRemotes?: SshRemoteConfig[];
sshRemoteConfig?: AgentSshRemoteConfig;
onSshRemoteConfigChange?: (config: AgentSshRemoteConfig) => void;
globalDefaultSshRemoteId?: string | null;
}
export function AgentConfigPanel({
@@ -290,10 +284,6 @@ export function AgentConfigPanel({
refreshingAgent = false,
compact = false,
showBuiltInEnvVars = false,
sshRemotes,
sshRemoteConfig,
onSshRemoteConfigChange,
globalDefaultSshRemoteId,
}: AgentConfigPanelProps): JSX.Element {
const padding = compact ? 'p-2' : 'p-3';
const spacing = compact ? 'space-y-2' : 'space-y-3';
@@ -567,133 +557,6 @@ export function AgentConfigPanel({
</p>
</div>
{/* SSH Remote Configuration - only shown when props are provided */}
{sshRemotes !== undefined && onSshRemoteConfigChange && (
<div
className={`${padding} rounded border`}
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
<label className="block text-xs font-medium mb-2" style={{ color: theme.colors.textDim }}>
SSH Remote Execution
</label>
{/* SSH Remote Selection */}
<div className="space-y-3">
{/* Dropdown to select remote */}
<div className="relative">
<select
value={
sshRemoteConfig?.enabled === false
? 'disabled'
: sshRemoteConfig?.remoteId || 'default'
}
onChange={(e) => {
const value = e.target.value;
if (value === 'disabled') {
// Explicitly disable SSH for this agent (run locally even if global default is set)
onSshRemoteConfigChange({
enabled: false,
remoteId: null,
});
} else if (value === 'default') {
// Use global default (or local if no global default)
onSshRemoteConfigChange({
enabled: true,
remoteId: null,
});
} else {
// Use specific remote
onSshRemoteConfigChange({
enabled: true,
remoteId: value,
});
}
}}
onClick={(e) => e.stopPropagation()}
className="w-full p-2 rounded border bg-transparent outline-none text-xs appearance-none cursor-pointer pr-8"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
backgroundColor: theme.colors.bgMain,
}}
>
<option value="default">
{globalDefaultSshRemoteId
? `Use Global Default (${sshRemotes.find(r => r.id === globalDefaultSshRemoteId)?.name || 'Unknown'})`
: 'Local Execution (No SSH Remote)'}
</option>
<option value="disabled">Force Local Execution</option>
{sshRemotes.filter(r => r.enabled).map((remote) => (
<option key={remote.id} value={remote.id}>
{remote.name} ({remote.host})
</option>
))}
</select>
<ChevronDown
className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: theme.colors.textDim }}
/>
</div>
{/* Status indicator showing effective remote */}
{(() => {
const effectiveRemoteId = sshRemoteConfig?.enabled === false
? null
: sshRemoteConfig?.remoteId || globalDefaultSshRemoteId || null;
const effectiveRemote = effectiveRemoteId
? sshRemotes.find(r => r.id === effectiveRemoteId && r.enabled)
: null;
const isForceLocal = sshRemoteConfig?.enabled === false;
return (
<div
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs"
style={{ backgroundColor: theme.colors.bgActivity }}
>
{isForceLocal ? (
<>
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
<span style={{ color: theme.colors.textDim }}>
Agent will run locally (SSH disabled)
</span>
</>
) : effectiveRemote ? (
<>
<Cloud className="w-3 h-3" style={{ color: theme.colors.success }} />
<span style={{ color: theme.colors.textMain }}>
Agent will run on <span className="font-medium">{effectiveRemote.name}</span>
<span style={{ color: theme.colors.textDim }}> ({effectiveRemote.host})</span>
</span>
</>
) : (
<>
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
<span style={{ color: theme.colors.textDim }}>
Agent will run locally
</span>
</>
)}
</div>
);
})()}
{/* No remotes configured hint */}
{sshRemotes.filter(r => r.enabled).length === 0 && (
<p className="text-xs" style={{ color: theme.colors.textDim }}>
No SSH remotes configured.{' '}
<span style={{ color: theme.colors.accent }}>
Configure remotes in Settings SSH Remotes.
</span>
</p>
)}
</div>
<p className="text-xs opacity-50 mt-2">
Execute this agent on a remote host via SSH instead of locally
</p>
</div>
)}
{/* Agent-specific configuration options (contextWindow, model, etc.) */}
{agent.configOptions && agent.configOptions.length > 0 && agent.configOptions.map((option: AgentConfigOption) => (
<div

View File

@@ -0,0 +1,162 @@
/**
* SshRemoteSelector.tsx
*
* Standalone component for SSH remote execution configuration.
* Extracted from AgentConfigPanel to be used at the top level of modals.
*
* Displays:
* - Dropdown to select SSH remote (or local execution)
* - Status indicator showing effective remote
* - Hint when no remotes are configured
*/
import { ChevronDown, Monitor, Cloud } from 'lucide-react';
import type { Theme } from '../../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../shared/types';
export interface SshRemoteSelectorProps {
theme: Theme;
sshRemotes: SshRemoteConfig[];
sshRemoteConfig?: AgentSshRemoteConfig;
onSshRemoteConfigChange: (config: AgentSshRemoteConfig) => void;
globalDefaultSshRemoteId?: string | null;
/** Optional: compact mode with less padding */
compact?: boolean;
}
export function SshRemoteSelector({
theme,
sshRemotes,
sshRemoteConfig,
onSshRemoteConfigChange,
globalDefaultSshRemoteId,
compact = false,
}: SshRemoteSelectorProps): JSX.Element {
const padding = compact ? 'p-2' : 'p-3';
return (
<div
className={`${padding} rounded border`}
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
<label className="block text-xs font-medium mb-2" style={{ color: theme.colors.textDim }}>
SSH Remote Execution
</label>
{/* SSH Remote Selection */}
<div className="space-y-3">
{/* Dropdown to select remote */}
<div className="relative">
<select
value={
sshRemoteConfig?.enabled === false
? 'disabled'
: sshRemoteConfig?.remoteId || 'default'
}
onChange={(e) => {
const value = e.target.value;
if (value === 'disabled') {
// Explicitly disable SSH for this agent (run locally even if global default is set)
onSshRemoteConfigChange({
enabled: false,
remoteId: null,
});
} else if (value === 'default') {
// Use global default (or local if no global default)
onSshRemoteConfigChange({
enabled: true,
remoteId: null,
});
} else {
// Use specific remote
onSshRemoteConfigChange({
enabled: true,
remoteId: value,
});
}
}}
onClick={(e) => e.stopPropagation()}
className="w-full p-2 rounded border bg-transparent outline-none text-xs appearance-none cursor-pointer pr-8"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
backgroundColor: theme.colors.bgMain,
}}
>
<option value="default">
{globalDefaultSshRemoteId
? `Use Global Default (${sshRemotes.find(r => r.id === globalDefaultSshRemoteId)?.name || 'Unknown'})`
: 'Local Execution (No SSH Remote)'}
</option>
<option value="disabled">Force Local Execution</option>
{sshRemotes.filter(r => r.enabled).map((remote) => (
<option key={remote.id} value={remote.id}>
{remote.name} ({remote.host})
</option>
))}
</select>
<ChevronDown
className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: theme.colors.textDim }}
/>
</div>
{/* Status indicator showing effective remote */}
{(() => {
const effectiveRemoteId = sshRemoteConfig?.enabled === false
? null
: sshRemoteConfig?.remoteId || globalDefaultSshRemoteId || null;
const effectiveRemote = effectiveRemoteId
? sshRemotes.find(r => r.id === effectiveRemoteId && r.enabled)
: null;
const isForceLocal = sshRemoteConfig?.enabled === false;
return (
<div
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs"
style={{ backgroundColor: theme.colors.bgActivity }}
>
{isForceLocal ? (
<>
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
<span style={{ color: theme.colors.textDim }}>
Agent will run locally (SSH disabled)
</span>
</>
) : effectiveRemote ? (
<>
<Cloud className="w-3 h-3" style={{ color: theme.colors.success }} />
<span style={{ color: theme.colors.textMain }}>
Agent will run on <span className="font-medium">{effectiveRemote.name}</span>
<span style={{ color: theme.colors.textDim }}> ({effectiveRemote.host})</span>
</span>
</>
) : (
<>
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
<span style={{ color: theme.colors.textDim }}>
Agent will run locally
</span>
</>
)}
</div>
);
})()}
{/* No remotes configured hint */}
{sshRemotes.filter(r => r.enabled).length === 0 && (
<p className="text-xs" style={{ color: theme.colors.textDim }}>
No SSH remotes configured.{' '}
<span style={{ color: theme.colors.accent }}>
Configure remotes in Settings SSH Remotes.
</span>
</p>
)}
</div>
<p className="text-xs opacity-50 mt-2">
Execute this agent on a remote host via SSH instead of locally
</p>
</div>
);
}