diff --git a/server/server.go b/server/server.go index 057d20f..0b0b6fc 100644 --- a/server/server.go +++ b/server/server.go @@ -3450,6 +3450,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { var req struct { Message string `json:"message"` Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录) + Seg *int `json:"seg,omitempty"` // 目标主会话分段;缺省=最新段 } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -3474,10 +3475,16 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { s.chatCancel[t.ID] = cancel s.chatMu.Unlock() - // The whole turn belongs to the current main-agent segment. Read it once (chatBusy - // serializes turns, so it can't change under us) and stamp every mainagent row with - // it, so this turn's transcript + activity land in the segment the user is viewing. - mainSeg, _ := t.Store.CurrentMainSeg() + // The turn belongs to whichever main-agent segment the user is chatting in (any + // segment is interactive, like the top-level chat conversations). Stamp every + // mainagent row with it so this turn's transcript + activity land in that segment. + // Missing seg (older clients) falls back to the newest segment. + mainSeg := 0 + if req.Seg != nil && *req.Seg >= 0 { + mainSeg = *req.Seg + } else { + mainSeg, _ = t.Store.CurrentMainSeg() + } segPtr := &mainSeg // Persist + broadcast the human turn so the 主 Agent 编排会话 survives page diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 832ebac..789892a 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -252,7 +252,6 @@ const MAIN_SESSION: Session = { live: true, last_activity: "", seg: 0, - isCurrentMain: true, }; // The planner session is, like the main-agent session, a fixed UI affordance with @@ -398,16 +397,6 @@ function SessionItem({ {unread > 99 ? "99+" : unread} ) : null} - {s.role === "mainagent" && s.isCurrentMain && !s.live && ( - - 当前 - - )} - {s.role === "mainagent" && !s.isCurrentMain && ( - - 历史 - - )} {s.live && ( @@ -1164,11 +1153,34 @@ export function SessionsTab({ taskId }: { taskId: string }) { [store], ); const currentMainKey = mainSessionKey(currentSeg); - const mainLive = sending || (mainChatRunning ?? recentLive(currentMainKey)); const plannerLive = recentLive("plan"); - // One switchable session per main-agent segment (newest-first). Only the current - // (highest) segment is live/writable; older ones are read-only history. + // Which main segment is streaming RIGHT NOW. A main turn is serialized per task, so at + // most one segment is live. Gate on the real running flag (sending / mainChatRunning) + // rather than "recent activity", and drop it the moment the turn's terminal record + // (kind='result', or an error) lands — otherwise the badge lingers for STREAM_WINDOW_MS + // after the agent already finished. null = nothing running. + const liveMainSeg = React.useMemo(() => { + if (!(sending || mainChatRunning)) return null; + // the streaming segment is the one with the freshest activity (incl. the just-sent turn) + let seg = currentSeg; + let bestTs = -1; + for (const m of mainSegs) { + const raw = store[mainSessionKey(m.seq)]?.lastTs; + const ts = raw ? Date.parse(raw) : -1; + if (ts > bestTs) { + bestTs = ts; + seg = m.seq; + } + } + const items = store[mainSessionKey(seg)]?.items ?? []; + const last = items[items.length - 1]; + if (last && (last.kind === "result" || (last.kind === "text" && last.is_error))) return null; + return seg; + }, [sending, mainChatRunning, mainSegs, store, currentSeg]); + + // Every main-agent segment is an independent, interactive session (like the top-level + // chat conversations) — you can talk in any of them, newest-first. const mainSessions = React.useMemo( () => mainSegs.map((m) => ({ @@ -1176,12 +1188,11 @@ export function SessionsTab({ taskId }: { taskId: string }) { role: "mainagent", title: mainSessionTitle(m.seq), status: "running", - live: m.seq === currentSeg && mainLive, + live: m.seq === liveMainSeg, last_activity: m.created_at, seg: m.seq, - isCurrentMain: m.seq === currentSeg, })), - [mainSegs, currentSeg, mainLive], + [mainSegs, liveMainSeg], ); const sessions = React.useMemo( @@ -1198,11 +1209,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION; const isMain = active.role === "mainagent"; - const isMainCurrent = isMain && (active.seg ?? 0) === currentSeg; // only the current segment is writable const isPlanner = active.role === "planner"; const isSystem = active.role === "system"; const activeKey = keyForSession(active); const activeState = store[activeKey]; + // A main turn is serialized per task (chat lock), so "busy" is task-wide: while any + // main segment is mid-turn the active composer is disabled. Clear it the moment the + // active session's terminal record (kind='result', or an error) lands, so the input + // re-enables immediately instead of waiting for the next chat-status poll. + const activeItems = activeState?.items ?? []; + const activeLast = activeItems[activeItems.length - 1]; + const activeSettled = + !!activeLast && (activeLast.kind === "result" || (activeLast.kind === "text" && activeLast.is_error)); + const mainBusy = isMain && (sending || (!activeSettled && (mainChatRunning ?? recentLive(activeKey)))); // 折叠态(手机端)标题栏要替代整张列表:显示当前会话名 + 其它会话的未读合计, // 否则收起后既不知道自己在看哪个会话,也看不到别处有新消息。 const activeDisplayTitle = (active.role === "worker" ? sessionMeta.get(active.id)?.title : "") || active.title; @@ -1400,7 +1419,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { setSending(true); chatStatusRequestRef.current++; api - .chat(text, taskId, atts.length > 0 ? atts : undefined) + .chat(text, taskId, atts.length > 0 ? atts : undefined, active.seg ?? 0) .then(({ mode }) => { chatStatusRequestRef.current++; setMainChatRunning(mode === "llm"); @@ -1770,30 +1789,12 @@ export function SessionsTab({ taskId }: { taskId: string }) { ) : (
- {isMainCurrent ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} + {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
)} - {isMain && !isMainCurrent ? ( -
- 这是历史会话(只读)。切回当前会话或新建会话以继续对话。 -
- - -
-
- ) : isMain ? ( + {isMain ? (
{attachments.length > 0 && (
@@ -1835,9 +1836,9 @@ export function SessionsTab({ taskId }: { taskId: string }) { onKeyDown={(e) => { if (!shouldSubmitOnKey(e, sendMode)) return; e.preventDefault(); - if (!mainLive) send(); + if (!mainBusy) send(); }} - disabled={mainLive} + disabled={mainBusy} className="max-h-36 min-h-9 overflow-y-auto" /> @@ -1845,13 +1846,13 @@ export function SessionsTab({ taskId }: { taskId: string }) { size="icon-xs" variant="ghost" onClick={() => fileInputRef.current?.click()} - disabled={mainLive || uploading} + disabled={mainBusy || uploading} title="上传文件" aria-label="上传文件" > {uploading ? : } - {mainLive ? ( + {mainBusy ? ( - post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), + chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) => + post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }), chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 2df07e0..b5bab8a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -831,7 +831,6 @@ export interface Session { source_task_id?: string; inherited?: boolean; seg?: number; // main-agent session: which conversation segment (0 = original) - isCurrentMain?: boolean; // main-agent session: the writable current segment } // ---- Security ----