Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Square } from "lucide-react";
import { ChatApiError } from "../../data/chat.js";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, type SteeringRequest } from "./steering-recovery";
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceMessage } from "./personal-workspace-model";

Expand All @@ -15,12 +16,23 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
const zh = locale === "zh-CN";
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const cacheKey = message.sourceSessionId && message.sourceTurnId
? JSON.stringify(["inline", message.sourceSessionId, message.sourceTurnId]) : null;
const [restoredRequest] = useState(() => cacheKey ? readSteeringRequest(cacheKey) : undefined);
const [editing, setEditing] = useState(Boolean(restoredRequest));
const [draft, setDraft] = useState(restoredRequest?.text ?? "");
const [steering, setSteering] = useState(false);
const [steerError, setSteerError] = useState<string | null>(null);
const [steerReceipt, setSteerReceipt] = useState(false);
const request = useRef<{ text: string; id: string } | null>(null);
const request = useRef<SteeringRequest | null>(restoredRequest ?? null);
useEffect(() => {
const restored = cacheKey ? readSteeringRequest(cacheKey) : undefined;
request.current = restored ?? null;
setDraft(restored?.text ?? "");
setEditing(Boolean(restored));
setSteerError(null);
setSteerReceipt(false);
}, [cacheKey]);
const activity = message.activity ?? [];
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
Expand All @@ -35,19 +47,43 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
: `${Math.floor(elapsed / 60)}${zh ? "分" : "m "}${elapsed % 60}${zh ? "秒" : "s"}`;
const quiet = message.updatedAt !== undefined && now - message.updatedAt >= 20000;

function updateDraft(text: string) {
setDraft(text);
const previous = request.current;
if (!text) {
if (cacheKey && previous) retireSteeringRequest(cacheKey, previous.id);
request.current = null;
return;
}
const next = { sessionId: message.sourceSessionId ?? "", turnId: message.sourceTurnId ?? "", text,
id: previous?.text.trim() === text.trim() ? previous.id : crypto.randomUUID() };
request.current = next;
if (cacheKey) retainSteeringRequest(cacheKey, next);
}

async function steer() {
const text = draft.trim();
if (!text || !message.pending || !message.sourceTurnId || !onSteerTurn || steering) return;
// Retain the operation identity after a lost response; retry cannot deliver twice.
if (request.current?.text !== text) request.current = { text, id: crypto.randomUUID() };
if (request.current?.text.trim() !== text) updateDraft(draft);
const sent = request.current!;
if (cacheKey) retainSteeringRequest(cacheKey, sent);
setSteering(true);
setSteerError(null);
try {
await onSteerTurn(message.sourceTurnId, text, request.current.id);
setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null;
await onSteerTurn(message.sourceTurnId, text, sent.id);
if (cacheKey) retireSteeringRequest(cacheKey, sent.id);
if (request.current?.id === sent.id) {
setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null;
}
} catch (cause) {
const definitelyNotDelivered = cause instanceof ChatApiError && cause.payload.delivery_state === "not_delivered";
if (definitelyNotDelivered) request.current = null;
if (definitelyNotDelivered && request.current?.id === sent.id) {
// Preserve the draft; only confirmed non-delivery can renew retry identity.
const next = { ...sent, id: crypto.randomUUID() };
request.current = next;
if (cacheKey && readSteeringRequest(cacheKey)?.id === sent.id) retainSteeringRequest(cacheKey, next);
}
const message = cause instanceof Error ? cause.message : (zh ? "未确认接收,草稿已保留。" : "Delivery unconfirmed. Draft retained.");
setSteerError(definitelyNotDelivered
? (zh ? "本次未送达;请检查当前回合与执行器,条件恢复后可重试原文。" : "Not delivered; check the current turn and executor, then retry the unchanged draft.")
Expand Down Expand Up @@ -85,7 +121,7 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
: "No new activity yet. Waiting for the executor to update."}</p> : null}
{editing ? <form className="personal-message-steer" onSubmit={event => { event.preventDefault(); void steer(); }}>
<label>{zh ? "追加给本轮的指令" : "Instructions for this turn"}<textarea value={draft} maxLength={12000} disabled={steering}
onChange={event => setDraft(event.target.value)} rows={3}/></label>
onChange={event => updateDraft(event.target.value)} rows={3}/></label>
<span>{message.pending
? (zh ? "调整当前工作,保持原有任务与会话。" : "Adjust the current work in this conversation.")
: (zh ? "本轮已结束,草稿已保留;可复制到输入框作为新消息发送。" : "This turn ended. Copy the retained draft to the composer to send a new message.")}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { goalCreateRequest } from "./goal-create-request";
import { persistComposerSteeringRequests, readComposerSteeringRequests, type ComposerSteeringRequest } from "./composer-steering-recovery";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest } from "./steering-recovery";
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
Expand Down Expand Up @@ -838,8 +838,6 @@ export function PersonalWorkspacePage({
});
const [sending, setSending] = useState(false);
const [steering, setSteering] = useState(false);
const [restoredSteeringRequests] = useState(readComposerSteeringRequests);
const steeringRequests = useRef(restoredSteeringRequests);
const [actionDraft, setActionDraft] = useState<WorkspaceActionDraft | null>(null);
const [loopxMode, setLoopxMode] = useState<LoopXModeSnapshot | null>(null);
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
Expand Down Expand Up @@ -904,17 +902,6 @@ export function PersonalWorkspacePage({
function setComposer(value: string) {
setComposerDraft(composerDraftKey, value);
}
function retainSteeringRequest(key: string, request: ComposerSteeringRequest) {
steeringRequests.current.set(key, request);
// Persist before sending: reload after provider acceptance must replay the
// original ingress, never silently submit another Turn.
persistComposerSteeringRequests(steeringRequests.current);
}
function retireSteeringRequest(key: string, id: string) {
if (steeringRequests.current.get(key)?.id !== id) return;
steeringRequests.current.delete(key);
persistComposerSteeringRequests(steeringRequests.current);
}
async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") {
// Source message + reviewed contents survive retry without merging distinct requests.
if (!edit && !draft.question && draft.completion_criteria.trim()) {
Expand Down Expand Up @@ -1681,7 +1668,7 @@ export function PersonalWorkspacePage({
const pendingImages = messageOverride ? [] : imageAttachments;
const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : "");
if (!message || composerBlocked || conversationHistoryState?.sendBlocked) return;
const previousSteering = steeringRequests.current.get(composerDraftKey);
const previousSteering = readSteeringRequest(composerDraftKey);
const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text === message
? previousSteering : undefined;
if ((retry || steeringTurnId) && conversationSessionId && callbacks.onSteerConversationTurn) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Local drafts/retry identity only; Chat ingress/store still owns delivery. */
export type SteeringRequest = {
sessionId: string;
turnId: string;
text: string;
id: string;
};

const storageKey = "loopx-pw-composer-steering";

let requests: Map<string, SteeringRequest> | undefined;

function restoreRequests(): Map<string, SteeringRequest> {
try {
const raw = window.sessionStorage.getItem(storageKey);
const entries: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(entries)) return new Map();
return new Map(entries.flatMap((entry): [string, SteeringRequest][] => {
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") return [];
const request = entry[1];
if (!request || typeof request !== "object" || Array.isArray(request)
|| ![request.sessionId, request.turnId, request.text, request.id]
.every(value => typeof value === "string" && value.length > 0)) return [];
return [[entry[0], { sessionId: request.sessionId, turnId: request.turnId, text: request.text, id: request.id }]];
}));
} catch {
return new Map();
}
}

function persistRequests() {
try {
window.sessionStorage.setItem(storageKey, JSON.stringify([...requestCache()]));
} catch {
// Unavailable browser storage retains drafts/retries in this shared page cache.
// No provider action or delivery conclusion follows from this cache.
}
}

// One cache prevents composer and inline controls from overwriting each other's
// entries. Lazy restoration also supports environments without browser storage.
function requestCache() {
return requests ??= restoreRequests();
}

export function readSteeringRequest(key: string) {
return requestCache().get(key);
}

export function retainSteeringRequest(key: string, request: SteeringRequest) {
requestCache().set(key, request);
persistRequests();
}

export function retireSteeringRequest(key: string, id: string) {
if (requestCache().get(key)?.id !== id) return;
requestCache().delete(key);
persistRequests();
}
12 changes: 12 additions & 0 deletions docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,18 @@ Artifact continuity is part of semantic continuity. Reuse the artifact owner to
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.

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.
The shared App activity control preserves an unsent or unconfirmed adjustment
across view changes and reload. Composer and inline controls use one TS client
cache; each inline draft is bound to its original session and Turn. Restoring a
draft dispatches nothing. Retries of unchanged text retain the same ingress
identity after unknown delivery or mismatched receipts; confirmed non-delivery
allows a fresh identity. Editing the instruction starts a distinct request.
Accepted delivery retires that exact entry without erasing another draft. An
ended Turn keeps its unsent draft readable and cannot redirect it to newer work.
Unavailable browser storage preserves page-memory recovery only, not reload
recovery. Packaged browser transport and the real Chat steering/store tests
qualify this bounded recovery behavior; live executor adoption, installed App
readback and Lark steering remain separate acceptance.

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.

Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,13 @@ Current status is design proposal; no G1 or default-screen promotion.
- **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.
- **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.

The GQ08 App correction path now retains inline adjustments and ingress identity
across view changes/reload through the same TS client cache as the composer.
Restoration never dispatches work, and a terminal Turn cannot inherit a new
Turn's target. Packaged recovery, blocked storage, coexisting drafts and real
Chat ingress/store regression are bounded evidence; live recipient adoption,
installed readback and Lark correction still belong to R3 qualification.

R3 also qualifies the handoff RFC's hierarchical A6/A8/A13 extension: a managed
worker requests, validates and integrates another peer's artifact before returning
to the lead. Reuse the same request and work owners at each level. The ingress
Expand Down
Loading
Loading