Skip to content

Commit 167d5aa

Browse files
fix(web): keep first Home run output live (#7071) (#7151)
* fix(web): keep first Home run output live * test(e2e): bound design system restoration probe * fix(web): preserve live turn through authority reload Generated-By: looper 0.11.8 (runner=fixer, agent=codex) (cherry picked from commit 479078b) Co-authored-by: Ray Xi <2667192167@qq.com>
1 parent 2d2c56f commit 167d5aa

5 files changed

Lines changed: 736 additions & 19 deletions

File tree

apps/web/src/components/ProjectView.tsx

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,8 @@ export function ProjectView({
18801880
: projectRunWorkspaceContext
18811881
? 'workspace'
18821882
: 'pending';
1883+
const projectResourceAuthorityRef = useRef(projectResourceAuthority);
1884+
projectResourceAuthorityRef.current = projectResourceAuthority;
18831885
const projectRunWorkspaceContextRef = useRef(projectRunWorkspaceContext);
18841886
projectRunWorkspaceContextRef.current = projectRunWorkspaceContext;
18851887
// The AMR pre-run balance gate uses the project's resolved scope, or the one
@@ -3003,27 +3005,60 @@ export function ProjectView({
30033005
const reloadingCurrentConversation =
30043006
messagesConversationIdRef.current === activeConversationId
30053007
&& messagesAuthorityKeyRef.current === projectRunAuthorityKey;
3008+
const liveReloadMessageIds = new Set<string>();
3009+
if (
3010+
messagesConversationIdRef.current === activeConversationId
3011+
&& streamingConversationIdRef.current === activeConversationId
3012+
&& abortRef.current !== null
3013+
&& cancelRef.current !== null
3014+
&& projectResourceAuthorityRef.current !== 'denied'
3015+
) {
3016+
const currentMessages = messagesRef.current;
3017+
let assistantIndex = -1;
3018+
for (let index = currentMessages.length - 1; index >= 0; index -= 1) {
3019+
const message = currentMessages[index];
3020+
if (message?.role === 'assistant' && isActiveRunStatus(message.runStatus)) {
3021+
liveReloadMessageIds.add(message.id);
3022+
assistantIndex = index;
3023+
break;
3024+
}
3025+
}
3026+
for (let index = assistantIndex - 1; index >= 0; index -= 1) {
3027+
const message = currentMessages[index];
3028+
if (message?.role !== 'user') continue;
3029+
liveReloadMessageIds.add(message.id);
3030+
break;
3031+
}
3032+
}
3033+
const preservingLiveConversation = liveReloadMessageIds.size > 0;
30063034
// Reset the initialized flag so auto-send waits for this authoritative DB
30073035
// read to settle before checking messages.length. A same-conversation
3008-
// authority refresh keeps the prior transcript visible, but invalidates
3009-
// its initialized state until the refresh succeeds.
3036+
// authority refresh keeps the prior transcript visible. An authority-key
3037+
// handoff keeps only the live turn, so its pending read cannot detach the
3038+
// stream or later replace those rows with an empty snapshot.
30103039
setMessagesInitialized(false);
30113040
let cancelled = false;
30123041
const requestWorkspaceContext = projectRunWorkspaceContextRef.current;
3013-
setMessagesConversationId(null);
30143042
setFailedMessagesConversationId(null);
3015-
setStreaming(false);
3016-
streamingConversationIdRef.current = null;
3017-
setStreamingConversationId(null);
3043+
if (!preservingLiveConversation) {
3044+
setMessagesConversationId(null);
3045+
setStreaming(false);
3046+
streamingConversationIdRef.current = null;
3047+
setStreamingConversationId(null);
3048+
}
30183049
if (!reloadingCurrentConversation) {
3019-
setMessages([]);
3050+
setMessages((current) =>
3051+
preservingLiveConversation
3052+
? current.filter((message) => liveReloadMessageIds.has(message.id))
3053+
: [],
3054+
);
30203055
commitPreviewComments([]);
30213056
setAttachedComments([]);
30223057
setArtifact(null);
30233058
savedArtifactRef.current = null;
30243059
}
30253060
const commentsGeneration = previewCommentsGenerationRef.current;
3026-
if (!reloadingCurrentConversation) {
3061+
if (!reloadingCurrentConversation && !preservingLiveConversation) {
30273062
messagesConversationIdRef.current = null;
30283063
messagesAuthorityKeyRef.current = null;
30293064
}
@@ -3051,7 +3086,14 @@ export function ProjectView({
30513086
requestWorkspaceContext,
30523087
);
30533088
if (cancelled) return;
3054-
setMessages(normalizeConversationMessageOrder(list));
3089+
setMessages((current) =>
3090+
preservingLiveConversation
3091+
? mergeServerMessagesIntoConversation(
3092+
current.filter((message) => liveReloadMessageIds.has(message.id)),
3093+
list,
3094+
)
3095+
: normalizeConversationMessageOrder(list),
3096+
);
30553097
setMessagesInitialized(true);
30563098
setAttachedComments([]);
30573099
setArtifact(null);
@@ -3065,16 +3107,22 @@ export function ProjectView({
30653107
if (cancelled) return;
30663108
const message = err instanceof Error ? err.message : 'Could not load messages for this conversation.';
30673109
if (!reloadingCurrentConversation) {
3068-
setMessages([]);
3110+
setMessages((current) =>
3111+
preservingLiveConversation
3112+
? current.filter((item) => liveReloadMessageIds.has(item.id))
3113+
: [],
3114+
);
30693115
commitPreviewComments([]);
30703116
setAttachedComments([]);
30713117
setArtifact(null);
30723118
savedArtifactRef.current = null;
30733119
}
30743120
setError(message);
3075-
messagesConversationIdRef.current = null;
3076-
messagesAuthorityKeyRef.current = null;
3077-
setMessagesConversationId(null);
3121+
if (!preservingLiveConversation) {
3122+
messagesConversationIdRef.current = null;
3123+
messagesAuthorityKeyRef.current = null;
3124+
setMessagesConversationId(null);
3125+
}
30783126
setFailedMessagesConversationId(activeConversationId);
30793127
}
30803128
})();
@@ -7043,14 +7091,45 @@ export function ProjectView({
70437091
let streamedText = '';
70447092

70457093
const updateAssistant = (updater: (prev: ChatMessage) => ChatMessage) => {
7046-
setMessages((curr) =>
7047-
curr.map((m) => {
7094+
setMessages((curr) => {
7095+
let found = false;
7096+
const next = curr.map((m) => {
70487097
if (m.id !== assistantId) return m;
7098+
found = true;
70497099
const updated = updater(m);
70507100
latestAssistantMsg = updated;
70517101
return updated;
7052-
}),
7053-
);
7102+
});
7103+
if (found) return next;
7104+
7105+
// A workspace-authority refresh can reload the same conversation
7106+
// while POST /runs is retrying. That authoritative read may still
7107+
// be empty and replace the Home handoff's client-owned user and
7108+
// assistant placeholders. The stream remains attached, however, so
7109+
// dropping later deltas here leaves a successful daemon run blank
7110+
// until a tab switch or reload reads the persisted transcript.
7111+
//
7112+
// Restore only the two rows owned by the live controller for the
7113+
// project and conversation still on screen. A revoked authority is
7114+
// never allowed to resurrect them, and settled historical messages
7115+
// keep the existing clear-on-authority-change behavior.
7116+
if (
7117+
abortRef.current !== controller
7118+
|| projectIdRef.current !== project.id
7119+
|| activeConversationIdRef.current !== runConversationId
7120+
|| projectResourceAuthorityRef.current === 'denied'
7121+
) {
7122+
return curr;
7123+
}
7124+
const updated = updater(latestAssistantMsg);
7125+
latestAssistantMsg = updated;
7126+
const userAlreadyPresent = curr.some((message) => message.id === userMsg.id);
7127+
return [
7128+
...curr,
7129+
...(userAlreadyPresent ? [] : [userMsg]),
7130+
updated,
7131+
];
7132+
});
70547133
};
70557134
let persistTimer: ReturnType<typeof setTimeout> | null = null;
70567135
const persistAssistantSoon = () => {

apps/web/tests/components/ProjectView.run-workspace-identity.test.tsx

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,207 @@ describe('a Home auto-send identifies its caller before the project scope resolv
646646
expect(mockedStreamViaDaemon).not.toHaveBeenCalled();
647647
});
648648

649+
it('keeps a cold Home run attached when an empty authority refresh settles after stream events', async () => {
650+
const activeStream = deferred<void>();
651+
let runOptions: Parameters<typeof streamViaDaemon>[0] | undefined;
652+
mockedStreamViaDaemon.mockImplementation((options) => {
653+
runOptions = options;
654+
return activeStream.promise;
655+
});
656+
657+
const view = renderProjectView();
658+
659+
await waitFor(() => expect(runOptions).toBeDefined());
660+
await waitFor(() => {
661+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([
662+
expect.objectContaining({ role: 'user', content: SEED_PROMPT }),
663+
expect.objectContaining({ role: 'assistant', runStatus: 'running' }),
664+
]);
665+
});
666+
667+
const authorityReload = deferred<ChatMessage[]>();
668+
mockedListMessages.mockReturnValueOnce(authorityReload.promise);
669+
workspaceScopeMocks.projectScope = {
670+
loading: false,
671+
scope: {
672+
kind: 'team',
673+
projectId: PROJECT_ID,
674+
workspaceId: TEAM_WORKSPACE,
675+
visibility: 'team',
676+
context: {
677+
...CALLER_CONTEXT,
678+
role: 'admin',
679+
permissions: buildWorkspacePermissions({
680+
role: 'admin',
681+
lifecycleState: 'active',
682+
}),
683+
} as WorkspaceCollabContext & { workspaceType: 'team' },
684+
},
685+
};
686+
await act(async () => {
687+
view.rerender(projectViewElement());
688+
});
689+
690+
await waitFor(() => expect(mockedListMessages).toHaveBeenCalledTimes(2));
691+
const expectedOutput = 'The first packaged run is still live.';
692+
await act(async () => {
693+
runOptions?.onRunCreated?.('run-first-home');
694+
runOptions?.onRunStatus?.('running');
695+
runOptions?.handlers.onAgentEvent({ kind: 'text', text: expectedOutput });
696+
runOptions?.handlers.onAgentEvent({
697+
kind: 'tool_use',
698+
id: 'write-index',
699+
name: 'Write',
700+
input: { file_path: 'index.html', content: '<main>ready</main>' },
701+
});
702+
});
703+
704+
await waitFor(() => {
705+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([
706+
expect.objectContaining({ role: 'user', content: SEED_PROMPT }),
707+
expect.objectContaining({
708+
role: 'assistant',
709+
runId: 'run-first-home',
710+
events: expect.arrayContaining([
711+
expect.objectContaining({ kind: 'text', text: expectedOutput }),
712+
]),
713+
}),
714+
]);
715+
});
716+
717+
await act(async () => {
718+
authorityReload.resolve([]);
719+
await authorityReload.promise;
720+
});
721+
722+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([
723+
expect.objectContaining({ role: 'user', content: SEED_PROMPT }),
724+
expect.objectContaining({
725+
role: 'assistant',
726+
runId: 'run-first-home',
727+
events: expect.arrayContaining([
728+
expect.objectContaining({ kind: 'text', text: expectedOutput }),
729+
]),
730+
}),
731+
]);
732+
733+
activeStream.resolve();
734+
});
735+
736+
it('keeps a terminal cold Home run attached when the empty refresh outlives its controller', async () => {
737+
const activeStream = deferred<void>();
738+
let runOptions: Parameters<typeof streamViaDaemon>[0] | undefined;
739+
mockedStreamViaDaemon.mockImplementation((options) => {
740+
runOptions = options;
741+
return activeStream.promise;
742+
});
743+
744+
const view = renderProjectView();
745+
await waitFor(() => expect(runOptions).toBeDefined());
746+
747+
const authorityReload = deferred<ChatMessage[]>();
748+
mockedListMessages.mockReturnValueOnce(authorityReload.promise);
749+
workspaceScopeMocks.projectScope = {
750+
loading: false,
751+
scope: {
752+
kind: 'team',
753+
projectId: PROJECT_ID,
754+
workspaceId: TEAM_WORKSPACE,
755+
visibility: 'team',
756+
context: {
757+
...CALLER_CONTEXT,
758+
role: 'admin',
759+
permissions: buildWorkspacePermissions({
760+
role: 'admin',
761+
lifecycleState: 'active',
762+
}),
763+
} as WorkspaceCollabContext & { workspaceType: 'team' },
764+
},
765+
};
766+
await act(async () => {
767+
view.rerender(projectViewElement());
768+
});
769+
await waitFor(() => expect(mockedListMessages).toHaveBeenCalledTimes(2));
770+
771+
const expectedOutput = 'The terminal first run stays visible.';
772+
await act(async () => {
773+
runOptions?.onRunCreated?.('run-terminal-home');
774+
runOptions?.onRunStatus?.('running');
775+
runOptions?.handlers.onAgentEvent({ kind: 'text', text: expectedOutput });
776+
runOptions?.handlers.onAgentEvent({
777+
kind: 'tool_use',
778+
id: 'write-terminal-index',
779+
name: 'Write',
780+
input: { file_path: 'index.html', content: '<main>complete</main>' },
781+
});
782+
runOptions?.onRunStatus?.('succeeded');
783+
});
784+
785+
await act(async () => {
786+
authorityReload.resolve([]);
787+
await authorityReload.promise;
788+
runOptions?.handlers.onDone('');
789+
});
790+
791+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([
792+
expect.objectContaining({ role: 'user', content: SEED_PROMPT }),
793+
expect.objectContaining({
794+
role: 'assistant',
795+
runId: 'run-terminal-home',
796+
endedAt: expect.any(Number),
797+
events: expect.arrayContaining([
798+
expect.objectContaining({ kind: 'text', text: expectedOutput }),
799+
]),
800+
}),
801+
]);
802+
803+
activeStream.resolve();
804+
});
805+
806+
it('does not restore a cold Home run after project authority is revoked', async () => {
807+
const activeStream = deferred<void>();
808+
let runOptions: Parameters<typeof streamViaDaemon>[0] | undefined;
809+
mockedStreamViaDaemon.mockImplementation((options) => {
810+
runOptions = options;
811+
return activeStream.promise;
812+
});
813+
814+
const view = renderProjectView();
815+
await waitFor(() => expect(runOptions).toBeDefined());
816+
817+
workspaceScopeMocks.ambientContext = null;
818+
workspaceScopeMocks.projectScope = {
819+
loading: false,
820+
scope: null,
821+
failure: 'forbidden',
822+
};
823+
await act(async () => {
824+
view.rerender(projectViewElement());
825+
});
826+
827+
await waitFor(() => expect(mockedListMessages).toHaveBeenCalledTimes(2));
828+
await waitFor(() => {
829+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([]);
830+
});
831+
832+
await act(async () => {
833+
runOptions?.onRunCreated?.('run-revoked-home');
834+
runOptions?.handlers.onAgentEvent({
835+
kind: 'text',
836+
text: 'This output belongs to the revoked authority.',
837+
});
838+
runOptions?.handlers.onAgentEvent({
839+
kind: 'tool_use',
840+
id: 'revoked-write',
841+
name: 'Write',
842+
input: { file_path: 'index.html', content: '<main>private</main>' },
843+
});
844+
});
845+
846+
expect(chatPaneSpy.mock.calls.at(-1)?.[0].messages).toEqual([]);
847+
activeStream.resolve();
848+
});
849+
649850
it('reuses one Home handoff identity across ProjectView remounts', async () => {
650851
const firstView = renderProjectView();
651852
await waitFor(() => expect(mockedStreamViaDaemon).toHaveBeenCalledTimes(1));

0 commit comments

Comments
 (0)