Skip to content

Commit 4feeef4

Browse files
authored
Merge pull request #496 from atomantic/cos/task-mpm54jto/agent-653b591f
fix: opaque merge modal + AI-assisted field merge for duplicate Universes/Series
2 parents 122380c + ca7e88b commit 4feeef4

13 files changed

Lines changed: 639 additions & 58 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ TBD
3131

3232
## Fixed
3333

34+
- **Universe / Series merge modal: opaque background + AI-assisted field merge.** The merge dialog (Sharing → Duplicates and the inline Universes-page resolver) had a transparent panel that bled into the underlying list, making the conflict editor hard to read. It now uses the standard `port-card` card chrome with the same `max-h-[85vh] overflow-y-auto` scroll envelope as the rest of the app's modals. Alongside that, conflicting *text* fields (e.g. two divergent `starterPrompt`s on a duplicate "Clandestiny") gain a **Merge with AI** button: the configured AI provider receives both values plus the survivor's style context (logline / style notes / embrace influences) and returns one unified value per field, which the modal renders in an editable textarea and ships as `fieldOverrides`. Each row keeps the original Keep-survivor / Use-folded picker — switching back discards the AI value for that row. Non-text conflicts (numeric, object-shaped) skip AI and stay on the manual picker. New routes: `POST /api/universe-builder/merge/ai-resolve` and `POST /api/pipeline/series/merge/ai-resolve`.
3435
- **Ollama model installs no longer die on a transient "EOF".** Pulling a model from the Local LLMs page could fail with a bare `EOF` status and leave the model uninstalled. Ollama streams pull progress as NDJSON, and a transient network hiccup between Ollama and the model registry/CDN surfaces mid-stream as an `{"error":"EOF"}` frame (or the response read dropping outright). PortOS gave up on the first occurrence — even though the `ollama` CLI silently retries these and the pull is resumable (partial blobs are kept). `pullModel` now retries the transient/EOF class up to 3 attempts with a linear backoff, continuing from the partial download rather than restarting; non-transient errors (bad model name, missing manifest) still fail fast. The install banner shows a "retrying after network error" notice during the backoff instead of stalling.
3536
- **TUI agents no longer try (and fail) to `/quit` at the end of a run.** The spawned-agent completion workflow used to tell the agent to write its `.agent-done` sentinel and then run `/quit`. In current Claude Code, `/quit` is a UI command that can't be invoked programmatically — so the agent attempted `Skill(quit)`, hit an error, and printed a confusing "please run /quit yourself" message before idling. The prompt now treats the sentinel as the sole done signal (PortOS finalizes the run within 2s of seeing it and closes the session itself) and explicitly tells the agent not to run `/quit`.
3637
- **Tabbed sub-nav strips no longer drift vertically on mobile.** The horizontally-scrolling sub-nav tabs (Media Gen and every other page that uses the shared `TabPills` primitive) could be dragged diagonally/vertically on iOS, producing an uneven, wonky scroll. Both `TabPills` scroll containers now set `touch-action: pan-x` so touch gestures pan the strip horizontally while vertical drags pass through to normal page scrolling.

client/src/components/sharing/DuplicatesTab.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export default function DuplicatesTab() {
3737

3838
useEffect(() => { load(); }, [load]);
3939

40-
const { merge, setMerge, openMerge, runPreview, executeMerge } = useRecordMerge({ onMerged: load });
40+
const { merge, setMerge, openMerge, runPreview, executeMerge, runAIMerge, updateOverride } = useRecordMerge({ onMerged: load });
4141

4242
const groupKey = (kind, normalizedName, scope = '') => `${kind}:${scope}:${normalizedName}`;
4343

@@ -83,6 +83,7 @@ export default function DuplicatesTab() {
8383
<MergeModal
8484
merge={merge} setMerge={setMerge} onExecute={executeMerge}
8585
onRepreview={(survivorId, loserId) => runPreview(merge.kind, survivorId, loserId, merge.records)}
86+
onAIMerge={runAIMerge} onUpdateOverride={updateOverride}
8687
/>
8788
)}
8889
</div>

client/src/components/sharing/DuplicatesTab.test.jsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ vi.mock('../../services/api', () => ({
99
mergeUniverses: vi.fn(),
1010
previewSeriesMerge: vi.fn(),
1111
mergeSeries: vi.fn(),
12+
aiResolveUniverseMerge: vi.fn(),
13+
aiResolveSeriesMerge: vi.fn(),
1214
updateUniverse: vi.fn(),
1315
updatePipelineSeries: vi.fn(),
1416
}));
15-
vi.mock('../ui/Toast', () => ({ default: { success: vi.fn(), error: vi.fn() } }));
17+
vi.mock('../ui/Toast', () => ({ default: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
1618
vi.mock('../ui/Modal', () => ({ default: ({ open, children }) => (open ? <div role="dialog">{children}</div> : null) }));
1719
vi.mock('../ui/InlineDiff', () => ({ default: ({ oldText, newText }) => <div data-testid="diff">{oldText}|{newText}</div> }));
1820

@@ -64,6 +66,46 @@ describe('DuplicatesTab', () => {
6466
));
6567
});
6668

69+
it('Merge with AI populates an editable override and ships it as fieldOverrides on submit', async () => {
70+
api.previewUniverseMerge.mockResolvedValue({
71+
conflicts: [{ field: 'starterPrompt', survivorValue: 'A', loserValue: 'B' }],
72+
cascade: { seriesToRepoint: [], loserCollectionItemCount: 0 },
73+
});
74+
api.aiResolveUniverseMerge.mockResolvedValue({
75+
merged: { starterPrompt: 'Unified A+B' }, skipped: [], llm: { provider: 'codex', model: null }, runId: 'r1',
76+
});
77+
api.mergeUniverses.mockResolvedValue({ merged: true });
78+
79+
const user = userEvent.setup();
80+
render(<DuplicatesTab />);
81+
await waitFor(() => expect(screen.getByText(/2 copies/)).toBeInTheDocument());
82+
83+
await user.click(screen.getByRole('button', { name: /Merge/ }));
84+
await waitFor(() => expect(screen.getByText('starterPrompt')).toBeInTheDocument());
85+
86+
// Click Merge with AI — the AI-resolve API is called with the conflict field list.
87+
await user.click(screen.getByRole('button', { name: /Merge with AI/ }));
88+
await waitFor(() => expect(api.aiResolveUniverseMerge).toHaveBeenCalledWith(
89+
{ survivorId: 'u-new', loserId: 'u-old', fields: ['starterPrompt'] },
90+
expect.anything(),
91+
));
92+
93+
// The AI-merged value is rendered in an editable textarea and the row
94+
// automatically switches to the "AI merged" choice.
95+
const ta = await screen.findByLabelText('AI-merged starterPrompt');
96+
expect(ta.value).toBe('Unified A+B');
97+
await user.clear(ta);
98+
await user.type(ta, 'Hand-tweaked unified');
99+
100+
// Execute — survivor/loser choice is dropped (server's enum doesn't accept 'ai'),
101+
// override is sent as fieldOverrides.
102+
await user.click(screen.getByRole('button', { name: /^Merge$/ }));
103+
await waitFor(() => expect(api.mergeUniverses).toHaveBeenCalledWith(
104+
{ survivorId: 'u-new', loserId: 'u-old', fieldChoices: {}, fieldOverrides: { starterPrompt: 'Hand-tweaked unified' } },
105+
expect.anything(),
106+
));
107+
});
108+
67109
it('renames a record inline via the update API', async () => {
68110
api.updateUniverse.mockResolvedValue({ id: 'u-old', name: 'Clandestiny (v2)' });
69111
const user = userEvent.setup();

client/src/components/sharing/MergeModal.jsx

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
/**
2-
* Merge modal for duplicate Universes / Series. Renders the survivor picker,
3-
* the per-field conflict resolver (survivor vs. folded value), and the cascade
4-
* summary, then fires the merge. Driven entirely by the `merge` state object
5-
* managed by `useRecordMerge` — shared between Sharing → Duplicates
6-
* (DuplicatesTab) and the inline resolver on the Universes page.
2+
* Merge modal for duplicate Universes / Series. Survivor picker + per-field
3+
* conflict resolver (survivor / folded / AI-merged) + cascade summary. Driven
4+
* by the `merge` state object from `useRecordMerge`. The AI path synthesizes
5+
* a unified value per text conflict that's editable in-place and shipped as
6+
* `fieldOverrides`; switching back to survivor/folded discards the override.
77
*/
88

9-
import { Loader2, GitMerge } from 'lucide-react';
9+
import { Loader2, GitMerge, Sparkles } from 'lucide-react';
1010
import Modal from '../ui/Modal';
1111
import InlineDiff from '../ui/InlineDiff';
12+
import { MERGE_CHOICE } from '../../hooks/useRecordMerge';
1213

1314
// Conflict values can be strings or structured (arrays/objects). Render strings
1415
// as-is so InlineDiff can word-diff them; pretty-print everything else.
@@ -17,11 +18,17 @@ const asText = (v) => {
1718
return typeof v === 'string' ? v : JSON.stringify(v, null, 2);
1819
};
1920

20-
export default function MergeModal({ merge, setMerge, onExecute, onRepreview }) {
21-
const { kind, records, survivorId, loserId, preview, choices, busy } = merge;
21+
// Only string-vs-string conflicts can be AI-merged. The server enforces the
22+
// same filter but we use it here too so the per-field "AI" button is only
23+
// shown when it would actually run.
24+
const isStringConflict = (c) => typeof c?.survivorValue === 'string' && typeof c?.loserValue === 'string';
25+
26+
export default function MergeModal({ merge, setMerge, onExecute, onRepreview, onAIMerge, onUpdateOverride }) {
27+
const { kind, records, survivorId, loserId, preview, choices, overrides = {}, busy, aiBusy } = merge;
2228
const conflicts = preview?.conflicts || [];
2329
const cascade = preview?.cascade || {};
2430
const multi = records.length > 2; // 3+ copies fold one pair at a time
31+
const aiEligible = conflicts.some(isStringConflict);
2532

2633
const setChoice = (field, val) => setMerge((m) => ({ ...m, choices: { ...m.choices, [field]: val } }));
2734
const swapSurvivor = (newSurvivorId) => {
@@ -35,7 +42,7 @@ export default function MergeModal({ merge, setMerge, onExecute, onRepreview })
3542

3643
return (
3744
<Modal open onClose={() => !busy && setMerge(null)} size="2xl" ariaLabel="Merge duplicates">
38-
<div className="p-5 space-y-4">
45+
<div className="bg-port-card border border-port-border rounded-lg p-5 space-y-4 max-h-[85vh] overflow-y-auto">
3946
<h2 className="text-lg font-semibold text-white flex items-center gap-2"><GitMerge size={18} /> Merge {kind}</h2>
4047

4148
<div>
@@ -71,20 +78,53 @@ export default function MergeModal({ merge, setMerge, onExecute, onRepreview })
7178

7279
{preview && conflicts.length > 0 && (
7380
<div>
74-
<label className="block text-xs text-gray-400 mb-2">{conflicts.length} conflicting field(s) — pick which value wins:</label>
81+
<div className="flex items-center justify-between mb-2 gap-2">
82+
<label className="block text-xs text-gray-400">{conflicts.length} conflicting field(s) — pick which value wins:</label>
83+
{onAIMerge && aiEligible && (
84+
<button
85+
type="button"
86+
onClick={onAIMerge}
87+
disabled={aiBusy || busy}
88+
title="Use the configured AI provider to synthesize a unified value per text-string field"
89+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded border border-port-accent/60 text-port-accent text-xs font-medium hover:bg-port-accent/10 disabled:opacity-50"
90+
>
91+
{aiBusy ? <Loader2 className="animate-spin" size={12} /> : <Sparkles size={12} />} Merge with AI
92+
</button>
93+
)}
94+
</div>
7595
<div className="space-y-3">
76-
{conflicts.map((c) => (
77-
<div key={c.field} className="border border-port-border rounded p-3">
78-
<div className="flex items-center justify-between mb-2">
79-
<span className="text-xs font-medium text-white">{c.field}</span>
80-
<div className="flex gap-1 text-[11px]">
81-
<button type="button" onClick={() => setChoice(c.field, 'survivor')} className={`px-2 py-0.5 rounded ${choices[c.field] === 'survivor' ? 'bg-port-accent text-white' : 'bg-port-bg text-gray-400'}`}>Keep survivor</button>
82-
<button type="button" onClick={() => setChoice(c.field, 'loser')} className={`px-2 py-0.5 rounded ${choices[c.field] === 'loser' ? 'bg-port-accent text-white' : 'bg-port-bg text-gray-400'}`}>Use folded</button>
96+
{conflicts.map((c) => {
97+
const choice = choices[c.field];
98+
const canAI = isStringConflict(c);
99+
const showOverride = choice === MERGE_CHOICE.AI;
100+
return (
101+
<div key={c.field} className="border border-port-border rounded p-3 bg-port-bg/40">
102+
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
103+
<span className="text-xs font-medium text-white">{c.field}</span>
104+
<div className="flex gap-1 text-[11px]">
105+
<button type="button" onClick={() => setChoice(c.field, MERGE_CHOICE.SURVIVOR)} className={`px-2 py-0.5 rounded ${choice === MERGE_CHOICE.SURVIVOR ? 'bg-port-accent text-white' : 'bg-port-bg text-gray-400'}`}>Keep survivor</button>
106+
<button type="button" onClick={() => setChoice(c.field, MERGE_CHOICE.LOSER)} className={`px-2 py-0.5 rounded ${choice === MERGE_CHOICE.LOSER ? 'bg-port-accent text-white' : 'bg-port-bg text-gray-400'}`}>Use folded</button>
107+
{canAI && typeof overrides[c.field] === 'string' && (
108+
<button type="button" onClick={() => setChoice(c.field, MERGE_CHOICE.AI)} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded ${choice === MERGE_CHOICE.AI ? 'bg-port-accent text-white' : 'bg-port-bg text-gray-400'}`}>
109+
<Sparkles size={10} /> AI merged
110+
</button>
111+
)}
112+
</div>
83113
</div>
114+
{showOverride ? (
115+
<textarea
116+
value={overrides[c.field] || ''}
117+
onChange={(e) => onUpdateOverride?.(c.field, e.target.value)}
118+
rows={Math.min(10, Math.max(3, (overrides[c.field] || '').split('\n').length + 1))}
119+
className="w-full px-2 py-1.5 rounded bg-port-bg border border-port-border text-sm text-white font-mono leading-snug"
120+
aria-label={`AI-merged ${c.field}`}
121+
/>
122+
) : (
123+
<InlineDiff oldText={asText(c.survivorValue)} newText={asText(c.loserValue)} />
124+
)}
84125
</div>
85-
<InlineDiff oldText={asText(c.survivorValue)} newText={asText(c.loserValue)} />
86-
</div>
87-
))}
126+
);
127+
})}
88128
</div>
89129
</div>
90130
)}
@@ -103,7 +143,7 @@ export default function MergeModal({ merge, setMerge, onExecute, onRepreview })
103143

104144
<div className="flex justify-end gap-2 pt-2">
105145
<button type="button" onClick={() => setMerge(null)} disabled={busy} className="px-3 py-2 rounded border border-port-border text-gray-300 text-sm">Cancel</button>
106-
<button type="button" onClick={onExecute} disabled={busy || !preview || survivorId === loserId}
146+
<button type="button" onClick={onExecute} disabled={busy || aiBusy || !preview || survivorId === loserId}
107147
className="inline-flex items-center gap-2 px-3 py-2 rounded bg-port-accent hover:bg-port-accent/90 text-white text-sm font-medium disabled:opacity-50">
108148
{busy ? <Loader2 className="animate-spin" size={14} /> : <GitMerge size={14} />} Merge
109149
</button>

0 commit comments

Comments
 (0)