Skip to content

Commit 9624316

Browse files
committed
fix(web): carry multi-skill compose list through Home auto-send (#6333 review)
PR #6333 added a multi-skill `@skill-a @skill-b` compose flow on the Home page by introducing a `skillIds` array alongside the existing primary `skillId` on the project-create payload. The array was correctly forwarded to `POST /api/projects`, but the Home auto-send hand-off persisted only prompt / attachments / context in sessionStorage — when `ProjectView` later fired the first `handleSend(...)`, the auto-send tail never supplied `meta.skillIds`, so `streamViaDaemon` launched the first run with only `project.skillId` and silently dropped the extra composed skills (#5824 continued to bite the main user flow). Changes: - `apps/web/src/App.tsx`: stash the caller-supplied `input.skillIds` in a dedicated `od:auto-send-skillIds:<projectId>` sessionStorage key alongside the existing auto-send hand-off keys; cleared symmetrically when the array is absent so a stale list from a cancelled create can't leak into the next project. - `apps/web/src/components/ProjectView.tsx`: - new `autoSendSkillIdsKey` helper + `readAutoSendSkillIds` reader (reuses the existing `isStoredStringArray` validator); - new `autoSendSkillIdsRef` captured at the same mount-time read as the other auto-send refs; - the auto-send effect now puts the array back into `meta.skillIds` on the first `handleSend(...)` call so the daemon stream receives the full compose list; - `clearAutoSendSession` also wipes the new key (reload after run start never re- fires with a stale skill set). - `apps/web/tests/components/ProjectView.run-cleanup.test.tsx`: regression test asserting the Home-staged multi-skill list reaches the first `streamViaDaemon` call as `skillIds: ['deck-builder', 'pdf-designer']` while the primary `skillId` stays on `project.skillId`, and that the auto-send session flag clears afterwards. All 66 cases in `ProjectView.run-cleanup.test.tsx` and all 22 cases in `ProjectView.pendingPrompt.test.tsx` pass. Signed-off-by: xxiaoxiong <2482929840@qq.com>
1 parent 1f1979c commit 9624316

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

apps/web/src/App.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,6 +1720,23 @@ function AppInner() {
17201720
`od:auto-send-context:${result.project.id}`,
17211721
);
17221722
}
1723+
// Multi-skill `@skill-a @skill-b` compose list. createProject
1724+
// already forwarded `input.skillIds` to the daemon, but the auto
1725+
// send hand-off used to only carry prompt/attachments/context to
1726+
// ProjectView — so the first run still launched with just
1727+
// `project.skillId` and the extra skills were silently lost
1728+
// (PR #6333 review). Stash the array in a dedicated key so the
1729+
// ProjectView auto-send path can put it back into `meta.skillIds`.
1730+
if (Array.isArray(input.skillIds) && input.skillIds.length > 0) {
1731+
window.sessionStorage.setItem(
1732+
`od:auto-send-skillIds:${result.project.id}`,
1733+
JSON.stringify(input.skillIds),
1734+
);
1735+
} else {
1736+
window.sessionStorage.removeItem(
1737+
`od:auto-send-skillIds:${result.project.id}`,
1738+
);
1739+
}
17231740
} catch {
17241741
/* sessionStorage may be unavailable (e.g. SSR / private mode); fall
17251742
back to manual send. */

apps/web/src/components/ProjectView.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,18 @@ function autoSendContextKey(projectId: string): string {
817817
return `od:auto-send-context:${projectId}`;
818818
}
819819

820+
/**
821+
* Home → Studio auto-send handoff for the multi-skill (`@skill-a @skill-b`)
822+
* compose list. Without this key, `ProjectView`'s auto-send hand-off fires
823+
* the first run with only `project.skillId` (the primary), silently
824+
* dropping the extra composed skills even though `handleCreateProject`
825+
* already forwarded `input.skillIds` to `POST /api/projects`. See #5824
826+
* and PR #6333 review.
827+
*/
828+
function autoSendSkillIdsKey(projectId: string): string {
829+
return `od:auto-send-skillIds:${projectId}`;
830+
}
831+
820832
/** Set by the home create flow when its submit already ran the Open Design
821833
* Cloud balance gate — the first auto-send must not re-prompt the user. */
822834
function autoSendAmrGateOkKey(projectId: string): string {
@@ -852,13 +864,27 @@ function readAutoSendContext(projectId: string): RunContextSelection | null {
852864
}
853865
}
854866

867+
/** Reads back the multi-skill `skillIds` array stashed at Home create time. */
868+
function readAutoSendSkillIds(projectId: string): string[] {
869+
if (typeof window === 'undefined') return [];
870+
try {
871+
const raw = window.sessionStorage.getItem(autoSendSkillIdsKey(projectId));
872+
if (!raw) return [];
873+
const parsed = JSON.parse(raw) as unknown;
874+
return isStoredStringArray(parsed) ? parsed : [];
875+
} catch {
876+
return [];
877+
}
878+
}
879+
855880
function clearAutoSendSession(projectId: string): void {
856881
if (typeof window === 'undefined') return;
857882
try {
858883
window.sessionStorage.removeItem(autoSendFirstMessageKey(projectId));
859884
window.sessionStorage.removeItem(autoSendAttachmentsKey(projectId));
860885
window.sessionStorage.removeItem(autoSendContextKey(projectId));
861886
window.sessionStorage.removeItem(autoSendAmrGateOkKey(projectId));
887+
window.sessionStorage.removeItem(autoSendSkillIdsKey(projectId));
862888
} catch {
863889
/* ignore */
864890
}
@@ -7741,6 +7767,7 @@ export function ProjectView({
77417767
const autoSendSeedRef = useRef<string | null>(null);
77427768
const autoSendAttachmentsRef = useRef<ChatAttachment[] | null>(null);
77437769
const autoSendContextRef = useRef<RunContextSelection | null>(null);
7770+
const autoSendSkillIdsRef = useRef<string[] | null>(null);
77447771
const autoSendFirstMessageRef = useRef(false);
77457772
const autoSendAmrGateOkRef = useRef(false);
77467773
if (autoSendSeedRef.current === null) {
@@ -7761,6 +7788,7 @@ export function ProjectView({
77617788
autoSendSeedRef.current = isAutoSend ? (project.pendingPrompt ?? '') : '';
77627789
autoSendAttachmentsRef.current = isAutoSend ? readAutoSendAttachments(project.id) : [];
77637790
autoSendContextRef.current = isAutoSend ? readAutoSendContext(project.id) : null;
7791+
autoSendSkillIdsRef.current = isAutoSend ? readAutoSendSkillIds(project.id) : [];
77647792
}
77657793
const initialWorkspaceContexts = autoSendContextRef.current?.workspaceItems ?? [];
77667794
const brandEnrichmentEligibleForProject =
@@ -8410,12 +8438,15 @@ export function ProjectView({
84108438
markDesignSystemAuditAutoRepairEligible(project.id);
84118439
}
84128440
clearAutoSendSession(project.id);
8441+
const skillIds = autoSendSkillIdsRef.current ?? [];
84138442
autoSendAttachmentsRef.current = [];
8443+
autoSendSkillIdsRef.current = [];
84148444
void handleSend(seed, attachments, [], {
84158445
...(context ? { context } : {}),
84168446
// The home submit already gated this exact task (and the user answered
84178447
// any soft warning there); asking again would double-prompt.
84188448
...(autoSendAmrGateOkRef.current ? { amrGatePrechecked: true } : {}),
8449+
...(skillIds.length > 0 ? { skillIds } : {}),
84198450
});
84208451
}, [
84218452
activeConversationId,

apps/web/tests/components/ProjectView.run-cleanup.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,86 @@ describe('ProjectView daemon cleanup', () => {
13991399
}
14001400
});
14011401

1402+
it('auto-sends the Home-carried multi-skill compose list to the first daemon run', async () => {
1403+
// Regression for PR #6333 review: the Home auto-send hand-off used to
1404+
// only carry prompt/attachments/context to ProjectView, so the
1405+
// extra composed skills (`@skill-a @skill-b`) were silently dropped
1406+
// — the first daemon run launched with only `project.skillId`
1407+
// (the primary). Stash `od:auto-send-skillIds:<projectId>` at Home
1408+
// create time, then ProjectView puts the array back into
1409+
// `meta.skillIds` on the first handleSend, and `streamViaDaemon`
1410+
// receives the full list rather than an empty one.
1411+
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
1412+
listMessages.mockResolvedValue([]);
1413+
fetchPreviewComments.mockResolvedValue([]);
1414+
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
1415+
fetchProjectFiles.mockResolvedValue([]);
1416+
fetchLiveArtifacts.mockResolvedValue([]);
1417+
fetchSkill.mockResolvedValue(null);
1418+
fetchDesignSystem.mockResolvedValue(null);
1419+
getTemplate.mockResolvedValue(null);
1420+
listActiveChatRuns.mockResolvedValue([]);
1421+
streamViaDaemon.mockResolvedValue(undefined);
1422+
1423+
window.sessionStorage.setItem('od:auto-send-first:project-skill-ids', '1');
1424+
window.sessionStorage.setItem(
1425+
'od:auto-send-skillIds:project-skill-ids',
1426+
JSON.stringify(['deck-builder', 'pdf-designer']),
1427+
);
1428+
1429+
try {
1430+
render(
1431+
<ProjectView
1432+
project={{
1433+
id: 'project-skill-ids',
1434+
name: 'Project',
1435+
skillId: 'deck-builder',
1436+
designSystemId: null,
1437+
pendingPrompt: 'Make a deck and a PDF.',
1438+
createdAt: 1,
1439+
updatedAt: 1,
1440+
}}
1441+
routeFileName={null}
1442+
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
1443+
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
1444+
skills={[]}
1445+
designTemplates={[]}
1446+
designSystems={[]}
1447+
daemonLive
1448+
onModeChange={() => {}}
1449+
onAgentChange={() => {}}
1450+
onAgentModelChange={() => {}}
1451+
onRefreshAgents={() => {}}
1452+
onOpenSettings={() => {}}
1453+
onBack={() => {}}
1454+
onClearPendingPrompt={() => {}}
1455+
onTouchProject={() => {}}
1456+
onProjectChange={() => {}}
1457+
onProjectsRefresh={() => {}}
1458+
/>,
1459+
);
1460+
1461+
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
1462+
const daemonCall = streamViaDaemon.mock.calls[0]?.[0] as {
1463+
projectId: string;
1464+
skillId: string | null;
1465+
skillIds: string[];
1466+
};
1467+
expect(daemonCall.projectId).toBe('project-skill-ids');
1468+
// The primary skill stays on `skillId` (the existing single-skill
1469+
// contract); the full multi-skill compose list lands on `skillIds`.
1470+
expect(daemonCall.skillId).toBe('deck-builder');
1471+
expect(daemonCall.skillIds).toEqual(['deck-builder', 'pdf-designer']);
1472+
// The auto-send session flag is cleared after the first dispatch so a
1473+
// reload after the run starts never re-fires.
1474+
expect(window.sessionStorage.getItem('od:auto-send-skillIds:project-skill-ids')).toBeNull();
1475+
expect(window.sessionStorage.getItem('od:auto-send-first:project-skill-ids')).toBeNull();
1476+
} finally {
1477+
window.sessionStorage.removeItem('od:auto-send-first:project-skill-ids');
1478+
window.sessionStorage.removeItem('od:auto-send-skillIds:project-skill-ids');
1479+
}
1480+
});
1481+
14021482
it('queues board comment attachments while the current daemon run is still busy', async () => {
14031483
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
14041484
listMessages.mockResolvedValue([]);

0 commit comments

Comments
 (0)