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
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@
.agent-work-unavailable {padding-block: 24px; color: var(--pw-muted); line-height: var(--leading-prose);}
.goal-agent-work-page .attached-conversation-content {flex: 1; display: flex; flex-direction: column; padding: 0 0 16px; overflow: auto;}
.goal-agent-work-page .attached-conversation-content > p {margin-block: 8px;}
.goal-agent-work-page .attached-conversation-source {font-size: var(--text-caption); color: var(--pw-muted);}
.goal-agent-work-page .attached-conversation-source {display: flex; align-items: center; gap: 8px; font-size: var(--text-caption); color: var(--pw-muted);}
.goal-agent-work-page .attached-conversation-messages {flex: 1; min-height: 160px; max-height: none; margin-block: 12px;}
.goal-agent-work-page .attached-conversation-content > button {align-self: flex-start; min-height: 44px;}
.goal-agent-work-page form {flex-shrink: 0; display: flex; gap: 12px; align-items: flex-end; border-top: 1px solid var(--pw-line); padding-top: 8px;}
.goal-agent-work-page form label {margin: 0; flex: 1; min-width: 0;}
.goal-agent-work-page form .attached-composer-input {display: grid; gap: 8px; flex: 1; min-width: 0;}
.attached-composer-label {display: flex; align-items: center; gap: 8px;}
.goal-agent-work-page form label {display: inline; margin: 0;}
.goal-agent-work-page form textarea {min-height: 64px; max-height: 160px;}
.goal-agent-work-page form button[type="submit"] {background: var(--pw-text); color: var(--pw-card); min-height: 44px;}
@media (max-width: 640px) {.agent-work-navigation {width: 100%;} .agent-work-navigation select {flex: 1; min-width: 0;} .goal-agent-work-page .attached-conversation-messages {min-height: 120px;}}
Expand All @@ -74,7 +76,7 @@
@media (prefers-reduced-motion: reduce) {.attached-activity-mark[data-active]::after {animation: none;}}
@media (max-width: 640px) {
.goal-agent-work-page form {flex-wrap: wrap;}
.goal-agent-work-page form label {flex-basis: 100%;}
.goal-agent-work-page form .attached-composer-input {flex-basis: 100%;}
.goal-agent-work-page form button[type="submit"] {margin-left: auto;}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {useEffect, useLayoutEffect, useRef, useState} from "react";
import {useEffect, useId, useLayoutEffect, useRef, useState} from "react";
import {Keyboard} from "lucide-react";
import {AttachedConversationTimeline, type ConversationRecord, type ContentPage} from "./attached-conversation-record";
import {composerKeyAction} from "./composer-keyboard";
import {ContextHelp} from "./context-help";
import "./attached-agent-conversations.css";

export type Binding = {agent_id: string; thread_id: string; binding_ref: string};
Expand Down Expand Up @@ -92,6 +95,9 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
const [draft, setDraft] = useState("");
const [submission, setSubmission] = useState<Submission | null>(null);
const [sending, setSending] = useState(false);
const sendingInput = useRef(false);
const composing = useRef(false);
const inputId = useId();
const [loadingOlder, setLoadingOlder] = useState(false);
const [opening, setOpening] = useState(false);
const [openRequested, setOpenRequested] = useState(false);
Expand Down Expand Up @@ -341,11 +347,12 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
}

async function send() {
if (sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return;
if (sendingInput.current || sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return;
sendingInput.current = true;
const current = generation.current;
const pending = {operation_id: crypto.randomUUID(), text: draft, status: "pending"};
try {sessionStorage.setItem(cacheKey, JSON.stringify(pending));}
catch {setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;}
catch {sendingInput.current = false; setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;}
receiptGeneration.current++;
setSubmission(pending); setSending(true); setSendNotice("");
try {
Expand All @@ -372,7 +379,7 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
} else {setError(String(failure)); void readReceipt(pending, current);}
}
}
finally {if (current === generation.current) setSending(false);}
finally {sendingInput.current = false; if (current === generation.current) setSending(false);}
}
async function readReceipt(pending = submission, current = generation.current) {
// Recover from existing evidence only. Never retry the native effect.
Expand Down Expand Up @@ -403,7 +410,7 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {

{recoveryError ? <p role="alert">{recoveryError} <button type="button" disabled={sending} onClick={nextReply}>{zh ? "已在原 App 核对,清理本页回执" : "Checked in original App; clear this receipt"}</button></p> : null}
{sendNotice ? <p role="status">{sendNotice}</p> : null}
<div className="attached-connection-status"><div><p className="attached-conversation-source" title={zh ? "已保存消息与工具记录自动更新;实时内容来自原宿主。" : "Saved messages and tools update automatically; live content comes from the original host."}>Codex App</p><p role="status">{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}</p></div>
<div className="attached-connection-status"><div><div className="attached-conversation-source">Codex App <ContextHelp label={zh ? "原会话来源与更新" : "Original conversation source and updates"}><p>{zh ? "已保存消息与工具记录自动更新;实时内容来自原宿主。" : "Saved messages and tools update automatically; live content comes from the original host."}</p></ContextHelp></div><p role="status">{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}</p></div>
<button type="button" className="attached-conversation-open" aria-disabled={opening} aria-busy={opening} onClick={() => void openOriginal()}>{zh ? "在 Codex App 中打开" : "Open in Codex App"}</button></div>
{openRequested ? <p role="status">{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}</p> : null}
{openError ? <p role="alert">{openError}</p> : null}
Expand All @@ -423,7 +430,23 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
{connectionError}
{liveUnavailable ? <p role="status">{zh ? "实时观察暂不可用,已保存的消息与工具记录仍会自动更新。" : "Live observation is unavailable; saved messages and tools still update automatically."}</p> : null}
{submission?.status ? <p role="status">{submission.status === "delivered" ? (zh ? "已由原 App 接纳;等待 Agent 结果。" : "Accepted by the original App; awaiting the Agent result.") : (zh ? "投递尚未确认,请先在原 App 核对,不会自动重发。" : "Delivery unconfirmed; check the original App. No automatic resend.")} {submission.status === "pending" ? <button type="button" disabled={sending} onClick={() => void readReceipt()}>{zh ? "重读投递回执" : "Recheck delivery receipt"}</button> : null}{awaitingReceipt ? <button type="button" disabled={sending} onClick={nextReply}>{zh ? "已核对,写下一条" : "Checked; write another"}</button> : null}</p> : null}
{!awaitingReceipt ? <form onSubmit={event => {event.preventDefault(); void send();}}><label>{zh ? `回复 ${binding.agent_id}` : `Reply to ${binding.agent_id}`}<textarea value={draft} maxLength={24000} disabled={sending} onChange={event => editDraft(event.target.value)}/></label><button type="submit" disabled={sending || Boolean(error || recoveryError) || !snapshot?.can_reply || !draft.trim()}>{sending ? (zh ? "正在交给原 App…" : "Sending to original App…") : (zh ? "发送到原会话" : "Send to original conversation")}</button></form> : null}
{!awaitingReceipt ? <form onSubmit={event => {event.preventDefault(); void send();}}><div className="attached-composer-input">
<div className="attached-composer-label"><label htmlFor={inputId}>{zh ? `回复 ${binding.agent_id}` : `Reply to ${binding.agent_id}`}</label>
<ContextHelp label={zh ? "回复快捷键" : "Reply keyboard shortcuts"} icon={Keyboard}><p>{zh ? "Enter 发送,Shift+Enter 换行;空闲时 Command+Enter 也会发送。此原会话入口目前仅支持空闲时回复;忙碌时保留草稿。" : "Enter sends, Shift+Enter adds a line, and Command+Enter also sends while idle. This original-conversation entry currently accepts replies only while idle; drafts remain available while busy."}</p></ContextHelp>
</div>
<textarea id={inputId} value={draft} maxLength={24000} disabled={sending} onChange={event => editDraft(event.target.value)}
onCompositionStart={() => {composing.current = true;}} onCompositionEnd={() => {composing.current = false;}}
onKeyDown={event => {
const action = composerKeyAction({...event, isComposing: event.nativeEvent.isComposing, keyCode: event.nativeEvent.keyCode}, composing.current);
if (!action) return;
event.preventDefault();
if (action === "steer" && snapshot?.activity.state !== "idle") {
setSendNotice(zh ? "此连接不支持调整本轮;草稿已保留。" : "This connection does not support steering; your draft is retained.");
} else if (!snapshot?.can_reply || error || recoveryError) {
setSendNotice(zh ? "原会话当前不能接收回复;草稿已保留。" : "The original conversation cannot accept a reply now; your draft is retained.");
} else void send();
}}/>
</div><button type="submit" disabled={sending || Boolean(error || recoveryError) || !snapshot?.can_reply || !draft.trim()}>{sending ? (zh ? "正在交给原 App…" : "Sending to original App…") : (zh ? "发送到原会话" : "Send to original conversation")}</button></form> : null}
</div>
;
}
Loading
Loading