POST "https://api.openai.com/v1/responses": 400 Bad Request {
"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"
}
50852
build/large-figure-gradient-2.icon/Assets/figure-large.svg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
51
build/large-figure-gradient-2.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50852
build/large-figure-gradient-bckgrnd.icon/Assets/figure-large.svg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 196 KiB |
43
build/large-figure-gradient-bckgrnd.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50852
build/maestro-circle-bckgrnd.icon/Assets/figure-large 2.svg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
112
build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-1 2.svg
Normal 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 |
@@ -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 |
@@ -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 |
43
build/maestro-circle-bckgrnd.icon/icon.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
build/maestro-purple-figure.icon/Assets/Image.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
52
build/maestro-purple-figure.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
162
src/renderer/components/shared/SshRemoteSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||