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
Original file line number Diff line number Diff line change
Expand Up @@ -1241,9 +1241,6 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
const dispatchTransport = session.config.dispatchJobId
? dispatchTransportByJobId[session.config.dispatchJobId]
: undefined;
const dispatchTransportError =
dispatchTransport?.lastTransportError?.trim()
|| t('nav.sessions.dispatchTransportErrorFallback');
const dispatchPresentation = isDispatched
? resolveDispatchNavPresentation({
targetLabel: dispatchTargetLabel,
Expand All @@ -1256,7 +1253,6 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
unreachableLabel: t('nav.sessions.dispatchUnreachable'),
unreachableSummary: t('nav.sessions.dispatchUnreachableDetails', {
target: dispatchTargetLabel,
error: dispatchTransportError,
}),
})
: null;
Expand Down
65 changes: 58 additions & 7 deletions src/web-ui/src/features/dispatch/DispatchInstallDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 120ms ease-out;

&:hover:not(:disabled),
&:focus-visible,
Expand All @@ -152,6 +156,10 @@
opacity: 0.55;
}

&:active:not(:disabled) {
transform: scale(0.99);
}

// Keep the leading policy icon aligned with the text column.
> svg:first-child {
margin-top: 1px;
Expand Down Expand Up @@ -182,15 +190,17 @@
}
}

// Baseline summary and the opt-in for carrying local Git-visible changes.
// Project summary and the opt-in for carrying local Git-visible changes.
// This is a normal setup choice, so it uses a neutral surface rather than a
// warning treatment.
&__consent {
display: flex;
flex-direction: column;
gap: $size-gap-2;
padding: $size-gap-3;
border: 1px solid var(--color-warning-border);
border: 1px solid var(--border-subtle);
border-radius: $size-radius-base;
background: var(--color-warning-bg);
background: var(--element-bg-subtle);
font-size: var(--font-size-xs);

code {
Expand Down Expand Up @@ -270,6 +280,27 @@
&[data-state='blocked'] > strong {
color: var(--color-warning);
}

&[data-state='pending'] > strong {
color: var(--color-text-secondary);
}
}
}

&__pending,
&__retry {
display: flex;
align-items: center;
gap: $size-gap-2;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}

&__retry {
justify-content: space-between;

> span {
flex: 1;
}
}

Expand Down Expand Up @@ -307,12 +338,26 @@
}
}

&__blockers {
margin: 0;
padding-left: 18px;
&__details {
width: 100%;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
line-height: 1.5;

summary {
width: fit-content;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;

&:hover,
&:focus-visible {
color: var(--color-text-primary);
}
}

&[open] summary {
margin-bottom: $size-gap-2;
}
}

&__output {
Expand Down Expand Up @@ -344,6 +389,12 @@
}
}

@media (prefers-reduced-motion: reduce) {
.dispatch-install-dialog__option {
transition-duration: 0.01ms;
}
}

@keyframes dispatch-install-spin {
to {
transform: rotate(360deg);
Expand Down
91 changes: 80 additions & 11 deletions src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ describe('DispatchInstallDialog installation lifecycle', () => {
expect(container.textContent).toContain('dispatch.installAutomaticTitle');
expect(container.textContent).toContain('1.2.3');
expect(container.textContent).toContain('abc123');
expect(container.querySelector('details')?.open).toBe(false);
expect(container.textContent).not.toContain('dispatch.installConfirm');
expect(mocks.modalLifecycleProps).toEqual({
closeOnOverlayClick: true,
Expand Down Expand Up @@ -335,8 +336,9 @@ describe('DispatchInstallDialog installation lifecycle', () => {
await Promise.resolve();
});

expect(container.textContent).toContain('target uses musl libc');
expect(container.textContent).toContain('no cargo on the target');
expect(container.textContent).toContain('dispatch.sourceBuildUnavailable');
expect(container.textContent).not.toContain('target uses musl libc');
expect(container.textContent).not.toContain('no cargo on the target');
const buttons = () => Array.from(container.querySelectorAll('button'));
expect(
buttons().find(button => button.textContent?.includes('dispatch.installConfirm')),
Expand Down Expand Up @@ -390,6 +392,63 @@ describe('DispatchInstallDialog installation lifecycle', () => {
expect(mocks.installCliSourceStart).toHaveBeenCalledWith('ssh-1');
});

it('keeps protocol capability names and probe failures out of the user interface', async () => {
mocks.probeTarget.mockResolvedValueOnce({
cliInstalled: true,
os: 'linux',
arch: 'x86_64',
installSupported: false,
protocol: {
protocolVersion: 4,
cliVersion: '1.2.3',
os: 'linux',
arch: 'x86_64',
capabilities: BASE_DISPATCH_CAPABILITIES.filter(
capability => capability !== 'workspace_git_sync',
),
modelConfigured: true,
availableModels: ['model-a'],
},
});

await act(async () => {
root.render(
<DispatchInstallDialog
open
target={{ kind: 'ssh', connectionId: 'ssh-1', displayName: 'build-host' }}
sourceWorkspacePath="/home/me/project"
onClose={vi.fn()}
onReady={vi.fn()}
/>,
);
await Promise.resolve();
await Promise.resolve();
});

expect(container.textContent).toContain('dispatch.cliUpdateRequired');
expect(container.textContent).not.toContain('workspace_git_sync');

mocks.probeTarget.mockRejectedValueOnce(
new Error('ssh handshake failed at internal transport stage'),
);
await act(async () => {
root.render(
<DispatchInstallDialog
open
target={{ kind: 'ssh', connectionId: 'ssh-2', displayName: 'backup-host' }}
sourceWorkspacePath="/home/me/project"
onClose={vi.fn()}
onReady={vi.fn()}
/>,
);
await Promise.resolve();
await Promise.resolve();
});

expect(container.textContent).toContain('dispatch.probeFailed');
expect(container.textContent).not.toContain('internal transport stage');
});

it('explains the Git baseline and never offers a snapshot delivery mode', async () => {
await act(async () => {
root.render(
Expand Down Expand Up @@ -584,13 +643,13 @@ describe('DispatchInstallDialog model configuration sync', () => {
.find(button => button.textContent?.includes('dispatch.syncModelConfirm'));
}

async function mount() {
async function mount(onClose = vi.fn()) {
await act(async () => {
root.render(
<DispatchInstallDialog
open
target={target}
onClose={vi.fn()}
onClose={onClose}
onReady={vi.fn()}
/>,
);
Expand All @@ -605,7 +664,9 @@ describe('DispatchInstallDialog model configuration sync', () => {
mocks.modalOnClose = null;
mocks.probeTarget.mockImplementation(async () => probeResult());
mocks.confirmWarning.mockResolvedValue(true);
mocks.getConfig.mockResolvedValue([]);
mocks.getConfig.mockResolvedValue([
{ id: 'claude', enabled: true, api_key: 'secret' },
]);
mocks.getFreshConfig.mockResolvedValue(undefined);
mocks.resolveRevision.mockResolvedValue('a'.repeat(40));
container = document.createElement('div');
Expand All @@ -618,7 +679,7 @@ describe('DispatchInstallDialog model configuration sync', () => {
container.remove();
});

it('keeps model sync available after the target reports a usable model', async () => {
it('hides model sync after the target matches this device', async () => {
await mount();
expect(syncButton()).toBeDefined();

Expand All @@ -638,7 +699,7 @@ describe('DispatchInstallDialog model configuration sync', () => {
expect(mocks.syncModelConfig).toHaveBeenCalledWith('ssh-1');
// The sync re-probes so the model check reflects the target, not the write.
expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSync);
expect(syncButton()).toBeDefined();
expect(syncButton()).toBeUndefined();
});

it('does not write the credential-bearing config when the confirmation is declined', async () => {
Expand All @@ -655,32 +716,40 @@ describe('DispatchInstallDialog model configuration sync', () => {
expect(syncButton()).toBeDefined();
});

it('discards a late sync acknowledgement after the dialog closes', async () => {
it('keeps the dialog open while model sync is in progress', async () => {
const sync = createDeferred<void>();
const onClose = vi.fn();
mocks.syncModelConfig.mockReturnValue(sync.promise);
await mount();
await mount(onClose);

await act(async () => {
syncButton()?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.syncModelConfig).toHaveBeenCalledTimes(1);
const probesBeforeClose = mocks.probeTarget.mock.calls.length;
const probesBeforeSettle = mocks.probeTarget.mock.calls.length;

await act(async () => {
mocks.modalOnClose?.();
await Promise.resolve();
});

expect(onClose).not.toHaveBeenCalled();
expect(mocks.modalLifecycleProps).toEqual({
closeOnOverlayClick: false,
showCloseButton: false,
});

await act(async () => {
modelConfigured = true;
sync.resolve(undefined);
await Promise.resolve();
await Promise.resolve();
});

expect(mocks.probeTarget.mock.calls.length).toBe(probesBeforeClose);
expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSettle);
expect(syncButton()).toBeUndefined();
});
});

Expand Down
Loading
Loading