Files
Netcatty/application/state/useWorkSurfaceHostEditor.ts
T
陈大猫 735c047a09 [Bug] Fix host connect script queue selection (#2705) (#2716)
* fix(hosts): keep draft connect scripts before onConnect promote

Host details queue editing pruned non-onConnect scripts immediately, so
default manual automation scripts could be selected but never appeared
in the queue. Keep an editable draft queue and only prune missing IDs
until save promotes triggers.

Closes #2705

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): promote persisted manual connect-queue scripts on save

Editable queue retention left already-saved manual script IDs unpromoted
when reopen/save compared only newly added IDs. Promote every queued
script that still needs onConnect + host targeting.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(hosts): drop unused HostDetailsPanel snippet imports

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): do not demote global scripts during queue save sync

Promote-on-save must leave targetsAllHosts onConnect scripts unchanged
when they appear in a host connectScriptIds list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(hosts): cover global connect scripts during queue save sync

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): promote connect-queue scripts on new host save

New hosts have no initialData, so queue promote-on-save was skipped and
manual scripts stayed non-onConnect after the first save.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): harden connect-queue promote against Codex review

Preserve global scope when promoting manuals, skip re-promoting scripts
demoted while the host editor is open, sync peer host queues for retained
targets, and keep test fixtures ASCII-only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): persist peer connect queues with edited host save

Peer queue sync via onHostsChange was overwritten by onSave rebuilding
from a stale hosts snapshot. Write peers+edited host together and make
save paths apply functional host updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): keep connect queues across hydration and concurrent edits

Skip queue rewrite while snippets are still empty, and do not promote
entries whose non-onConnect trigger changed while the host editor stayed
open.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): skip connect-queue rewrite before snippets hydrate

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): preserve concurrent target removals and merge peer deltas

Drop stale queue entries when targets were unlinked while editing, and
apply peer connectScriptIds updates via functional host state merges.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): hydrate connect-queue baseline and append peer deltas

Capture the first hydrated snippet catalog as the editor baseline, and
merge peer queue updates by appending only newly queued script IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): snapshot opening queue and keep peer updates host-safe

Use the editor-opening connect queue as sync previous IDs, and never
overwrite the edited host inside peer delta merges.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): clear deleted queues after hydrate and honor manual unlinks

Treat a hydrated empty snippet catalog as ready for queue reconciliation,
and drop concurrently untargeted non-onConnect queue entries on save.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hosts): clear loginScriptId when connect queue is emptied

sanitizeHost reconstitutes connectScriptIds from loginScriptId when the
explicit queue is absent, so clearing the last queued script must also
drop the legacy field.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:53:37 +08:00

172 lines
4.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { upsertHostById } from '../../domain/host';
import type { Host } from '../../types';
export type WorkSurfaceHostEditorMode = 'new' | 'edit';
export type WorkSurfaceHostEditorTarget =
| { mode: 'new'; defaultGroup: string | null; requestId: number }
| { mode: 'edit'; openedHost: Host; requestId: number };
interface UseWorkSurfaceHostEditorOptions {
hosts: Host[];
onUpdateHosts: (hosts: Host[]) => void;
onSaved?: (mode: WorkSurfaceHostEditorMode) => void;
}
export function buildWorkSurfaceHostEditorKey(target: WorkSurfaceHostEditorTarget): string {
if (target.mode === 'edit') {
return `edit:${target.openedHost.id}:${target.requestId}`;
}
return `new:${target.defaultGroup ?? 'root'}:${target.requestId}`;
}
export function shouldCloseDeletedWorkSurfaceHost(
hosts: Host[],
target: WorkSurfaceHostEditorTarget | null,
): boolean {
return target?.mode === 'edit'
&& !hosts.some((host) => host.id === target.openedHost.id);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function areDraftValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left)
&& Array.isArray(right)
&& left.length === right.length
&& left.every((value, index) => areDraftValuesEqual(value, right[index]));
}
if (!isPlainRecord(left) || !isPlainRecord(right)) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length
&& leftKeys.every((key) => Object.hasOwn(right, key)
&& areDraftValuesEqual(left[key], right[key]));
}
function mergeWorkSurfaceDraftValue(
openedValue: unknown,
draftValue: unknown,
latestValue: unknown,
): unknown {
if (areDraftValuesEqual(draftValue, openedValue)) return latestValue;
if (!isPlainRecord(draftValue) || !isPlainRecord(latestValue)) {
return draftValue;
}
const openedRecord = isPlainRecord(openedValue) ? openedValue : {};
const merged: Record<string, unknown> = { ...latestValue };
const draftKeys = new Set([...Object.keys(openedRecord), ...Object.keys(draftValue)]);
for (const key of draftKeys) {
if (!Object.hasOwn(draftValue, key)) {
delete merged[key];
continue;
}
merged[key] = mergeWorkSurfaceDraftValue(
openedRecord[key],
draftValue[key],
latestValue[key],
);
}
return merged;
}
export function mergeWorkSurfaceHostDraft(
openedHost: Host,
draft: Host,
latestHost: Host,
): Host {
return mergeWorkSurfaceDraftValue(openedHost, draft, latestHost) as Host;
}
export function saveWorkSurfaceHostDraft(
hosts: Host[],
target: WorkSurfaceHostEditorTarget,
draft: Host,
): Host[] | null {
if (target.mode === 'new') {
return upsertHostById(hosts, draft);
}
const latestHost = hosts.find((host) => host.id === target.openedHost.id);
if (!latestHost) return null;
return upsertHostById(
hosts,
mergeWorkSurfaceHostDraft(target.openedHost, draft, latestHost),
);
}
export function useWorkSurfaceHostEditor({
hosts,
onUpdateHosts,
onSaved,
}: UseWorkSurfaceHostEditorOptions) {
const [target, setTarget] = useState<WorkSurfaceHostEditorTarget | null>(null);
const requestIdRef = useRef(0);
const nextRequestId = useCallback(() => {
requestIdRef.current += 1;
return requestIdRef.current;
}, []);
const openNew = useCallback((defaultGroup?: string | null) => {
setTarget({
mode: 'new',
defaultGroup: defaultGroup || null,
requestId: nextRequestId(),
});
}, [nextRequestId]);
const openEdit = useCallback((host: Host) => {
setTarget({ mode: 'edit', openedHost: host, requestId: nextRequestId() });
}, [nextRequestId]);
const close = useCallback(() => {
setTarget(null);
}, []);
const save = useCallback((draft: Host) => {
if (!target) return;
let saved = false;
onUpdateHosts((prevHosts) => {
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
if (!nextHosts) return prevHosts;
saved = true;
return nextHosts;
});
if (!saved) {
close();
return;
}
onSaved?.(target.mode);
close();
}, [close, onSaved, onUpdateHosts, target]);
useEffect(() => {
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
close();
}
}, [close, hosts, target]);
const editorKey = useMemo(
() => (target ? buildWorkSurfaceHostEditorKey(target) : null),
[target],
);
return {
target,
editorKey,
openNew,
openEdit,
close,
save,
};
}