Skip to content

Commit b853293

Browse files
committed
fix(chat): retain inline steering drafts and retry identity
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 98acf52 commit b853293

7 files changed

Lines changed: 166 additions & 62 deletions

File tree

‎apps/presentation/dashboard/src/features/personal-workspace/composer-steering-recovery.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

‎apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef, useState } from "react";
22
import { Square } from "lucide-react";
33
import { ChatApiError } from "../../data/chat.js";
4+
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, type SteeringRequest } from "./steering-recovery";
45
import { useWorkspaceI18n } from "./i18n";
56
import type { WorkspaceMessage } from "./personal-workspace-model";
67

@@ -15,12 +16,23 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
1516
const zh = locale === "zh-CN";
1617
const [stopping, setStopping] = useState(false);
1718
const [error, setError] = useState<string | null>(null);
18-
const [editing, setEditing] = useState(false);
19-
const [draft, setDraft] = useState("");
19+
const cacheKey = message.sourceSessionId && message.sourceTurnId
20+
? JSON.stringify(["inline", message.sourceSessionId, message.sourceTurnId]) : null;
21+
const [restoredRequest] = useState(() => cacheKey ? readSteeringRequest(cacheKey) : undefined);
22+
const [editing, setEditing] = useState(Boolean(restoredRequest));
23+
const [draft, setDraft] = useState(restoredRequest?.text ?? "");
2024
const [steering, setSteering] = useState(false);
2125
const [steerError, setSteerError] = useState<string | null>(null);
2226
const [steerReceipt, setSteerReceipt] = useState(false);
23-
const request = useRef<{ text: string; id: string } | null>(null);
27+
const request = useRef<SteeringRequest | null>(restoredRequest ?? null);
28+
useEffect(() => {
29+
const restored = cacheKey ? readSteeringRequest(cacheKey) : undefined;
30+
request.current = restored ?? null;
31+
setDraft(restored?.text ?? "");
32+
setEditing(Boolean(restored));
33+
setSteerError(null);
34+
setSteerReceipt(false);
35+
}, [cacheKey]);
2436
const activity = message.activity ?? [];
2537
const [now, setNow] = useState(() => Date.now());
2638
useEffect(() => {
@@ -35,19 +47,43 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
3547
: `${Math.floor(elapsed / 60)}${zh ? "分" : "m "}${elapsed % 60}${zh ? "秒" : "s"}`;
3648
const quiet = message.updatedAt !== undefined && now - message.updatedAt >= 20000;
3749

50+
function updateDraft(text: string) {
51+
setDraft(text);
52+
const previous = request.current;
53+
if (!text) {
54+
if (cacheKey && previous) retireSteeringRequest(cacheKey, previous.id);
55+
request.current = null;
56+
return;
57+
}
58+
const next = { sessionId: message.sourceSessionId ?? "", turnId: message.sourceTurnId ?? "", text,
59+
id: previous?.text.trim() === text.trim() ? previous.id : crypto.randomUUID() };
60+
request.current = next;
61+
if (cacheKey) retainSteeringRequest(cacheKey, next);
62+
}
63+
3864
async function steer() {
3965
const text = draft.trim();
4066
if (!text || !message.pending || !message.sourceTurnId || !onSteerTurn || steering) return;
4167
// Retain the operation identity after a lost response; retry cannot deliver twice.
42-
if (request.current?.text !== text) request.current = { text, id: crypto.randomUUID() };
68+
if (request.current?.text.trim() !== text) updateDraft(draft);
69+
const sent = request.current!;
70+
if (cacheKey) retainSteeringRequest(cacheKey, sent);
4371
setSteering(true);
4472
setSteerError(null);
4573
try {
46-
await onSteerTurn(message.sourceTurnId, text, request.current.id);
47-
setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null;
74+
await onSteerTurn(message.sourceTurnId, text, sent.id);
75+
if (cacheKey) retireSteeringRequest(cacheKey, sent.id);
76+
if (request.current?.id === sent.id) {
77+
setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null;
78+
}
4879
} catch (cause) {
4980
const definitelyNotDelivered = cause instanceof ChatApiError && cause.payload.delivery_state === "not_delivered";
50-
if (definitelyNotDelivered) request.current = null;
81+
if (definitelyNotDelivered && request.current?.id === sent.id) {
82+
// Preserve the draft; only confirmed non-delivery can renew retry identity.
83+
const next = { ...sent, id: crypto.randomUUID() };
84+
request.current = next;
85+
if (cacheKey && readSteeringRequest(cacheKey)?.id === sent.id) retainSteeringRequest(cacheKey, next);
86+
}
5187
const message = cause instanceof Error ? cause.message : (zh ? "未确认接收,草稿已保留。" : "Delivery unconfirmed. Draft retained.");
5288
setSteerError(definitelyNotDelivered
5389
? (zh ? "本次未送达;请检查当前回合与执行器,条件恢复后可重试原文。" : "Not delivered; check the current turn and executor, then retry the unchanged draft.")
@@ -85,7 +121,7 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
85121
: "No new activity yet. Waiting for the executor to update."}</p> : null}
86122
{editing ? <form className="personal-message-steer" onSubmit={event => { event.preventDefault(); void steer(); }}>
87123
<label>{zh ? "追加给本轮的指令" : "Instructions for this turn"}<textarea value={draft} maxLength={12000} disabled={steering}
88-
onChange={event => setDraft(event.target.value)} rows={3}/></label>
124+
onChange={event => updateDraft(event.target.value)} rows={3}/></label>
89125
<span>{message.pending
90126
? (zh ? "调整当前工作,保持原有任务与会话。" : "Adjust the current work in this conversation.")
91127
: (zh ? "本轮已结束,草稿已保留;可复制到输入框作为新消息发送。" : "This turn ended. Copy the retained draft to the composer to send a new message.")}</span>

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { goalCreateRequest } from "./goal-create-request";
2-
import { persistComposerSteeringRequests, readComposerSteeringRequests, type ComposerSteeringRequest } from "./composer-steering-recovery";
2+
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest } from "./steering-recovery";
33
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
44
import { GoalDraftCard } from "./goal-draft-card";
55
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
@@ -838,8 +838,6 @@ export function PersonalWorkspacePage({
838838
});
839839
const [sending, setSending] = useState(false);
840840
const [steering, setSteering] = useState(false);
841-
const [restoredSteeringRequests] = useState(readComposerSteeringRequests);
842-
const steeringRequests = useRef(restoredSteeringRequests);
843841
const [actionDraft, setActionDraft] = useState<WorkspaceActionDraft | null>(null);
844842
const [loopxMode, setLoopxMode] = useState<LoopXModeSnapshot | null>(null);
845843
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
@@ -904,17 +902,6 @@ export function PersonalWorkspacePage({
904902
function setComposer(value: string) {
905903
setComposerDraft(composerDraftKey, value);
906904
}
907-
function retainSteeringRequest(key: string, request: ComposerSteeringRequest) {
908-
steeringRequests.current.set(key, request);
909-
// Persist before sending: reload after provider acceptance must replay the
910-
// original ingress, never silently submit another Turn.
911-
persistComposerSteeringRequests(steeringRequests.current);
912-
}
913-
function retireSteeringRequest(key: string, id: string) {
914-
if (steeringRequests.current.get(key)?.id !== id) return;
915-
steeringRequests.current.delete(key);
916-
persistComposerSteeringRequests(steeringRequests.current);
917-
}
918905
async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") {
919906
// Source message + reviewed contents survive retry without merging distinct requests.
920907
if (!edit && !draft.question && draft.completion_criteria.trim()) {
@@ -1681,7 +1668,7 @@ export function PersonalWorkspacePage({
16811668
const pendingImages = messageOverride ? [] : imageAttachments;
16821669
const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : "");
16831670
if (!message || composerBlocked || conversationHistoryState?.sendBlocked) return;
1684-
const previousSteering = steeringRequests.current.get(composerDraftKey);
1671+
const previousSteering = readSteeringRequest(composerDraftKey);
16851672
const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text === message
16861673
? previousSteering : undefined;
16871674
if ((retry || steeringTurnId) && conversationSessionId && callbacks.onSteerConversationTurn) {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/** Local drafts/retry identity only; Chat ingress/store still owns delivery. */
2+
export type SteeringRequest = {
3+
sessionId: string;
4+
turnId: string;
5+
text: string;
6+
id: string;
7+
};
8+
9+
const storageKey = "loopx-pw-composer-steering";
10+
11+
let requests: Map<string, SteeringRequest> | undefined;
12+
13+
function restoreRequests(): Map<string, SteeringRequest> {
14+
try {
15+
const raw = window.sessionStorage.getItem(storageKey);
16+
const entries: unknown = raw ? JSON.parse(raw) : [];
17+
if (!Array.isArray(entries)) return new Map();
18+
return new Map(entries.flatMap((entry): [string, SteeringRequest][] => {
19+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") return [];
20+
const request = entry[1];
21+
if (!request || typeof request !== "object" || Array.isArray(request)
22+
|| ![request.sessionId, request.turnId, request.text, request.id]
23+
.every(value => typeof value === "string" && value.length > 0)) return [];
24+
return [[entry[0], { sessionId: request.sessionId, turnId: request.turnId, text: request.text, id: request.id }]];
25+
}));
26+
} catch {
27+
return new Map();
28+
}
29+
}
30+
31+
function persistRequests() {
32+
try {
33+
window.sessionStorage.setItem(storageKey, JSON.stringify([...requestCache()]));
34+
} catch {
35+
// Unavailable browser storage retains drafts/retries in this shared page cache.
36+
// No provider action or delivery conclusion follows from this cache.
37+
}
38+
}
39+
40+
// One cache prevents composer and inline controls from overwriting each other's
41+
// entries. Lazy restoration also supports environments without browser storage.
42+
function requestCache() {
43+
return requests ??= restoreRequests();
44+
}
45+
46+
export function readSteeringRequest(key: string) {
47+
return requestCache().get(key);
48+
}
49+
50+
export function retainSteeringRequest(key: string, request: SteeringRequest) {
51+
requestCache().set(key, request);
52+
persistRequests();
53+
}
54+
55+
export function retireSteeringRequest(key: string, id: string) {
56+
if (requestCache().get(key)?.id !== id) return;
57+
requestCache().delete(key);
58+
persistRequests();
59+
}

‎docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,18 @@ Artifact continuity is part of semantic continuity. Reuse the artifact owner to
490490
Long-running work must remain legible in the existing conversation: what is being attempted, who owns the next step, what is actually blocking, and what conclusion is owed. Provide expandable tool/artifact activity and current semantic context in the frontend; Lark gets a concise equivalent and actionable results. Distinguish a queued worker, unavailable host, permission refusal, website login and undelivered answer. Do not expose raw protocol envelopes or claim fine-grained tool activity on adapters that cannot supply it.
491491

492492
The App must also preserve the user's chosen work view across reload and browser history. Manager conversation and Goal overview/tasks/conversation/artifacts use the existing typed workspace route; source loading must not insert duplicate history entries. Preserve the source and Goal identity, retain an unsent draft under its existing owner, and never start or replay a Turn merely to restore navigation. Fresh-entry defaults remain distinct from restoring an explicit view. Packaged navigation recovery qualifies this presentation boundary only; actual receiver adoption, steering and owed-result return retain their independent acceptance requirements.
493+
The shared App activity control preserves an unsent or unconfirmed adjustment
494+
across view changes and reload. Composer and inline controls use one TS client
495+
cache; each inline draft is bound to its original session and Turn. Restoring a
496+
draft dispatches nothing. Retries of unchanged text retain the same ingress
497+
identity after unknown delivery or mismatched receipts; confirmed non-delivery
498+
allows a fresh identity. Editing the instruction starts a distinct request.
499+
Accepted delivery retires that exact entry without erasing another draft. An
500+
ended Turn keeps its unsent draft readable and cannot redirect it to newer work.
501+
Unavailable browser storage preserves page-memory recovery only, not reload
502+
recovery. Packaged browser transport and the real Chat steering/store tests
503+
qualify this bounded recovery behavior; live executor adoption, installed App
504+
readback and Lark steering remain separate acceptance.
493505

494506
Reuse existing capability instructions, context hooks, memory and scheduling owners. Reusable methods may inform planning and handoff, with their source/version visible; remembered lessons do not replace accepted task state or current verification. Stable recurring work may use the existing schedule/event path after its task and replay behavior are understood. This RFC does not add a routine engine, compulsory method learning, a new hook family or business-specific automation.
495507

‎docs/architecture/rfcs/loopx-overall-roadmap-v0.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,13 @@ Current status is design proposal; no G1 or default-screen promotion.
625625
- **Exit:** actual manager→worker and worker→worker callers; follow-up messages, lost source session, oversized answer, duplicate callback, successful send with lost ACK and transport restart. CLI, packaged frontend and Lark read back the same result with audience isolation. Ordinary already-authorized work gains no second confirmation.
626626
- **Migration/rollback:** characterize first, record old writer/reader mappings and deletion payoff; disabling new production must leave old requests drainable. Do not retain two writable lifecycles.
627627

628+
The GQ08 App correction path now retains inline adjustments and ingress identity
629+
across view changes/reload through the same TS client cache as the composer.
630+
Restoration never dispatches work, and a terminal Turn cannot inherit a new
631+
Turn's target. Packaged recovery, blocked storage, coexisting drafts and real
632+
Chat ingress/store regression are bounded evidence; live recipient adoption,
633+
installed readback and Lark correction still belong to R3 qualification.
634+
628635
R3 also qualifies the handoff RFC's hierarchical A6/A8/A13 extension: a managed
629636
worker requests, validates and integrates another peer's artifact before returning
630637
to the lead. Reuse the same request and work owners at each level. The ingress

0 commit comments

Comments
 (0)