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
3 changes: 2 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ export async function createChatSession(
agentId?: string,
mode: "resume_latest" | "new" = "resume_latest",
contextKind: "goal" | "manager" = "goal",
signal?: AbortSignal,
) {
return requestJson<{
agent_id: string;
Expand All @@ -651,7 +652,7 @@ export async function createChatSession(
session_id: string;
session: ChatSessionSummary;
}>("/api/chat/sessions", {
method: "POST",
method: "POST", signal,
// An omitted ``agent_id`` means "no explicit executor pick": the channel
// owner resolves its own default. Sending this client's own default would
// silently re-point the steward channel away from its configured executor.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { MessageActivity } from "./message-activity";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import {Fragment, useRef, useState} from "react";
import { ChatApiError } from "../../data/chat.js";
import {Fragment} from "react";
import { CollaborationCard } from "./collaboration-card";
import { Activity, Bot, Sparkles, Square } from "lucide-react";
import { Activity, Bot, Sparkles } from "lucide-react";

import { AttentionRow } from "./cards/attention-row";
import { MIN_SEPARATE_ANSWER_LENGTH } from "./answer-text";
Expand All @@ -15,7 +15,7 @@ import { useWorkspaceI18n } from "./i18n";
import { ReturnDeliveryStatus } from "./return-delivery-status";
import {ManagerTeamResult} from "./manager-team-result";
import { compareProposalRecency } from "./proposal-recency";
import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceMessage, WorkspaceTimelineItem } from "./personal-workspace-model";
import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceTimelineItem } from "./personal-workspace-model";

function answerLink(sessionId: string, messageId: string) {
const url = new URL(window.location.href);
Expand All @@ -26,80 +26,6 @@ function answerLink(sessionId: string, messageId: string) {
return url.toString();
}

function MessageActivity({ message, onInterruptTurn, onSteerTurn }: {
message: WorkspaceMessage;
onInterruptTurn?: (turnId: string) => Promise<void>;
onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise<void>;
}) {
const { locale, t } = useWorkspaceI18n();
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 [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 activity = message.activity ?? [];
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() };
setSteering(true);
setSteerError(null);
try {
await onSteerTurn(message.sourceTurnId, text, request.current.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;
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.")
: message);
} finally { setSteering(false); }
}
async function interrupt() {
if (!message.sourceTurnId || !onInterruptTurn || stopping) return;
setStopping(true);
setError(null);
try { await onInterruptTurn(message.sourceTurnId); }
catch (cause) { setError(cause instanceof Error ? cause.message : (zh ? "中断失败,请重试。" : "Could not interrupt. Try again.")); }
finally { setStopping(false); }
}
return <div className="personal-message-work">
{message.pending ? <div className="personal-message-work-current">
<span className="personal-message-pending">{activity.at(-1) || t("timeline.pending")}</span>
<span className="personal-message-work-actions">
{message.sourceTurnId && onSteerTurn ? <button type="button" disabled={steering || stopping} onClick={() => { setEditing(!editing); setSteerReceipt(false); }} aria-expanded={editing}>
{zh ? "调整本轮" : "Adjust turn"}
</button> : null}
{message.sourceTurnId && onInterruptTurn ? <button type="button" disabled={stopping} onClick={() => void interrupt()}>
<Square size={12} aria-hidden="true"/>{stopping ? (zh ? "正在中断…" : "Interrupting…") : (zh ? "中断本轮" : "Interrupt turn")}
</button> : null}
</span>
</div> : 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>
<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>
<button type="submit" disabled={!message.pending || !draft.trim() || steering || stopping}>
{steering ? (zh ? "正在发送…" : "Sending…") : (zh ? "发送调整" : "Send adjustment")}
</button>
{steerError ? <p className="personal-message-work-error" role="alert">{steerError}</p> : null}
</form> : null}
{steerReceipt ? <p className="personal-message-steer-receipt" role="status">{zh ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn."}</p> : null}
{activity.length ? <details className="personal-message-activity">
<summary>{zh ? "最近活动" : "Recent activity"}<span>{activity.length}</span></summary>
<ol>{activity.map((label, index) => <li key={`${index}:${label}`}>{label}</li>)}</ol>
</details> : null}
{message.pending && error ? <p className="personal-message-work-error" role="alert">{error}</p> : null}
</div>;
}

export function ChannelTimeline({
items,
Expand All @@ -111,8 +37,10 @@ export function ChannelTimeline({
onOpenGoalEvidence,
onInterruptTurn,
onSteerTurn,
onCancelPreparation,
}: {
items: WorkspaceTimelineItem[];
onCancelPreparation?: () => void;
onSelect: (selection: WorkspaceDrawerSelection) => void;
selectedGoal: WorkspaceGoal | null;
showManagerTeamResults?: boolean;
Expand Down Expand Up @@ -217,7 +145,7 @@ export function ChannelTimeline({
? <a className="personal-answer-link" href={answerLink(item.message.sourceSessionId, item.message.sourceMessageId)}
target="_blank" rel="noopener noreferrer">{locale === "zh-CN" ? "单独阅读完整答复" : "Read full answer separately"}</a>
: null}
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? <MessageActivity message={item.message} onInterruptTurn={onInterruptTurn} onSteerTurn={onSteerTurn}/> : null}
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? <MessageActivity message={item.message} onCancelPreparation={onCancelPreparation} onInterruptTurn={onInterruptTurn} onSteerTurn={onSteerTurn}/> : null}
{item.message.role === "assistant" && !item.message.pending && item.message.goalDraft
? <GoalDraftCard draftId={`${item.message.sourceSessionId ?? ""}:${item.message.id}`} draft={item.message.goalDraft} onReview={onReviewGoalDraft} onSuggest={onSuggestReply}/> : null}
<CollaborationCard request={item.message.collaboration} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from "react";
import { Square } from "lucide-react";
import { ChatApiError } from "../../data/chat.js";
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceMessage } from "./personal-workspace-model";

// One work surface for the conversation timeline and compact overview receipt.
export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCancelPreparation }: {
message: WorkspaceMessage;
onCancelPreparation?: () => void;
onInterruptTurn?: (turnId: string) => Promise<void>;
onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise<void>;
}) {
const { locale } = useWorkspaceI18n();
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 [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 activity = message.activity ?? [];
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!message.pending) return;
setNow(Date.now());
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [message.pending]);
const elapsed = message.startedAt === undefined ? null
: Math.max(0, Math.floor(((message.endedAt ?? now) - message.startedAt) / 1000));
const duration = elapsed === null ? null : elapsed < 60 ? `${elapsed}${zh ? "秒" : "s"}`
: `${Math.floor(elapsed / 60)}${zh ? "分" : "m "}${elapsed % 60}${zh ? "秒" : "s"}`;
const quiet = message.updatedAt !== undefined && now - message.updatedAt >= 20000;

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() };
setSteering(true);
setSteerError(null);
try {
await onSteerTurn(message.sourceTurnId, text, request.current.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;
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.")
: message);
} finally { setSteering(false); }
}
async function interrupt() {
if (!message.sourceTurnId || !onInterruptTurn || stopping) return;
setStopping(true);
setError(null);
try { await onInterruptTurn(message.sourceTurnId); }
catch (cause) { setError(cause instanceof Error ? cause.message : (zh ? "中断失败,请重试。" : "Could not interrupt. Try again.")); }
finally { setStopping(false); }
}
return <div className="personal-message-work">
{message.pending ? <div className="personal-message-work-current">
<span className="personal-message-work-status">
<span className="personal-message-pending">{activity.at(-1) || (zh ? "等待执行器更新" : "Waiting for executor update")}</span>
{duration ? <span className="personal-message-elapsed" aria-live="off">{zh ? "已用时 " : "Elapsed "}{duration}</span> : null}
</span>
<span className="personal-message-work-actions">
{message.preparing && onCancelPreparation ? <button type="button" onClick={onCancelPreparation}>
<Square size={12} aria-hidden="true"/>{zh ? "取消发送" : "Cancel send"}
</button> : null}
{message.sourceTurnId && onSteerTurn ? <button type="button" disabled={steering || stopping} onClick={() => { setEditing(!editing); setSteerReceipt(false); }} aria-expanded={editing}>
{zh ? "调整本轮" : "Adjust turn"}
</button> : null}
{message.sourceTurnId && onInterruptTurn ? <button type="button" disabled={stopping} onClick={() => void interrupt()}>
<Square size={12} aria-hidden="true"/>{stopping ? (zh ? "正在中断…" : "Interrupting…") : (zh ? "中断本轮" : "Interrupt turn")}
</button> : null}
</span>
</div> : null}
{message.pending && quiet ? <p className="personal-message-quiet" role="status">{zh
? "暂时没有新的进展,仍在等待执行器更新。"
: "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>
<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>
<button type="submit" disabled={!message.pending || !draft.trim() || steering || stopping}>
{steering ? (zh ? "正在发送…" : "Sending…") : (zh ? "发送调整" : "Send adjustment")}
</button>
{steerError ? <p className="personal-message-work-error" role="alert">{steerError}</p> : null}
</form> : null}
{steerReceipt ? <p className="personal-message-steer-receipt" role="status">{zh ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn."}</p> : null}
{!message.pending && message.endedAt && duration ? <p className="personal-message-elapsed">{zh ? "已结束 · 用时 " : "Ended · Elapsed "}{duration}</p> : null}
{activity.length ? <details className="personal-message-activity">
<summary>{zh ? "最近活动" : "Recent activity"}<span>{activity.length}</span></summary>
<ol>{activity.map((label, index) => <li key={`${index}:${label}`}>{label}</li>)}</ol>
</details> : null}
{message.pending && error ? <p className="personal-message-work-error" role="alert">{error}</p> : null}
</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ export type WorkspaceMessage = {
attachments?: WorkspaceImageAttachment[];
id: string;
pending?: boolean;
preparing?: boolean;
/** Observed request/turn times, independent of component mount or view changes. */
startedAt?: number;
updatedAt?: number;
endedAt?: number;
returnDelivery?: WorkspaceReturnDelivery;
role: "assistant" | "user" | "system";
sourceTurnId?: string;
Expand Down Expand Up @@ -420,6 +425,7 @@ export type PersonalWorkspaceCallbacks = {
onExplainDecision?: (attention: WorkspaceAttention) => void | Promise<void>;
onExportOutput?: (output: WorkspaceOutput) => void | Promise<void>;
onInterruptRun?: (run: WorkspaceRun) => void | Promise<void>;
onCancelConversationPreparation?: (contextId: string) => void;
onInterruptConversationTurn?: (contextId: string, turnId: string) => Promise<void>;
onSteerConversationTurn?: (contextId: string, turnId: string, message: string, ingressId: string) => Promise<void>;
onOpenGoal?: (goalId: string) => void | Promise<void>;
Expand Down
Loading
Loading