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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

### Added

- #617:接力任务支持可选本步要求,以及与模型输入一致的交接快照;可查看前序结果来源、裁剪和过滤提示,共享现有上下文预算并去除重复来源。新增本地记录字段,降级旧版本前请备份工作区。

### Added

- #615: Show per-task execution progress in project cards, schedule rows, and the task editor. Reuse live stage events and exact persisted snapshots, recover on reconnect, and keep completed execution separate from delivery acceptance.
- #466:审批队列和详情展示可选 Jev 老化建议(`nudge` / `wait` / `discuss-close`),仅为文案。出站载荷只有 `{ createdAt, now, decision.kind, risk }`。overlay 不得隐藏 pending high,不得 POST decision,点击只打开事项。过期仍走现有 clock helpers。`ROLEWEAVE_LAYA_ENABLED` 默认关。https://github.com/bytefolk/roleweave/issues/422

Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const SERVER_RUNTIME_FILES = [
"dist/src/turns/delta-forwarder.js",
"dist/src/turns/envelope.js",
"dist/src/turns/progress.js",
"dist/src/turns/relay-handoff.js",
"dist/src/turns/response-format.js",
"dist/src/turns/running.js",
"dist/src/turns/store.js",
Expand Down Expand Up @@ -194,6 +195,7 @@ const SHARED_RUNTIME_FILES = [
"pending-approval.cjs",
"position-id.cjs",
"turn-engines.cjs",
"relay-steps.cjs",
"dist/acceptance.js",
"dist/agent-binding.js",
"dist/api.js",
Expand Down Expand Up @@ -223,6 +225,7 @@ const SHARED_RUNTIME_FILES = [
"dist/position-id.js",
"dist/position-profile.js",
"dist/relationship-graph.js",
"dist/relay-steps.js",
"dist/semantic-runtime.js",
"dist/services.js",
"dist/sessions.js",
Expand Down Expand Up @@ -325,6 +328,9 @@ const APP_RESOURCE_REQUIRED_ENTRIES = [
"node_modules/@roleweave/shared/position-id.cjs",
"node_modules/@roleweave/shared/pending-approval.cjs",
"node_modules/@roleweave/shared/turn-engines.cjs",
"node_modules/@roleweave/shared/relay-steps.cjs",
"node_modules/@roleweave/shared/dist/relay-steps.js",
"apps/server/dist/src/turns/relay-handoff.js",
"examples/oss-maintainer/workspace.json",
"examples/oss-maintainer/organization.v1alpha1.json",
];
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/renderer/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -9110,3 +9110,17 @@ body,
@media (prefers-reduced-motion: reduce) { .owb-turn-panel *, .owb-org-module__splitter::before { animation: none !important; transition: none !important; } }

.owb-turn-panel .owb-tc-head__eng { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }

/* Bounded relay notes and optional per-step requirements. */
.owb-relay-handoff, .owb-relay-instructions { min-width: 0; margin-block: 8px; }
.owb-relay-handoff summary, .owb-relay-instructions summary { cursor: pointer; overflow-wrap: anywhere; }
.owb-relay-handoff__body { max-height: 420px; overflow: auto; padding: 8px 0; }
.owb-relay-handoff dl { margin: 8px 0; }
.owb-relay-handoff dt { font-weight: 600; }
.owb-relay-handoff dd { margin: 4px 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.owb-relay-handoff section { border-top: 1px solid var(--ui-border); padding-block: 10px; }
.owb-relay-handoff pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
.owb-relay-handoff code { overflow-wrap: anywhere; }
.owb-relay-instructions__fields { display: grid; gap: 10px; max-height: 240px; overflow: auto; padding: 8px 0; }
.owb-relay-instructions__fields label { display: grid; gap: 4px; min-width: 0; }
.owb-relay-instructions small { color: var(--ui-foreground-muted); }
37 changes: 32 additions & 5 deletions apps/desktop/renderer/src/groups/GroupsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createPortal } from "react-dom";
import { Button as AntButton, Input, Select as AntSelect } from "antd";
import { ArrowUp, Plus, Search, Trash2, UserRound, UserRoundPlus, UsersRound } from "lucide-react";
import { useOwbLocale, useT } from "@roleweave/ui";
import { RelayHandoffDetails } from "./RelayHandoffDetails";
import { instructionBytes, selectedRelaySteps, relayStepsTooLarge, RELAY_INSTRUCTION_BYTES } from "./relay-instructions";
import { PositionAvatar } from "../PositionAvatar";
import { DiagnosticNotice, type AvailabilityCheck } from "../DiagnosticNotice";
import { ProgressTrail } from "../turns/TurnThread";
Expand Down Expand Up @@ -208,8 +210,8 @@ export function GroupsPanel({
const [createOpen, setCreateOpen] = useState(false);
const [draftMembers, setDraftMembers] = useState<ReadonlySet<string>>(new Set());
const draftKey = selectedRef ?? "";
const [drafts, setDrafts] = useState<Record<string, { input: string; mentions: string[]; mode: "parallel" | "relay" }>>({});
const emptyDraft = { input: "", mentions: [] as string[], mode: "parallel" as const };
const [drafts, setDrafts] = useState<Record<string, { input: string; mentions: string[]; mode: "parallel" | "relay"; instructions: Record<string, string> }>>({});
const emptyDraft = { input: "", mentions: [] as string[], mode: "parallel" as const, instructions: {} as Record<string, string> };
const draft = drafts[draftKey] ?? emptyDraft;
const input = draft.input;
const mentions = useMemo(() => new Set(draft.mentions), [draft.mentions]);
Expand All @@ -230,6 +232,12 @@ export function GroupsPanel({
);
const mentionsReady = unavailableMention === undefined;
const dispatchMode = draft.mode;
const relaySteps = selectedRelaySteps(mentionedPositionIds, draft.instructions);
const invalidRelaySteps = dispatchMode === "relay" && relayStepsTooLarge(relaySteps);
const setStepInstruction = (positionId: string, instruction: string) => setDrafts(current => {
const previous = current[draftKey] ?? emptyDraft;
return { ...current, [draftKey]: { ...previous, instructions: { ...previous.instructions, [positionId]: instruction } } };
});
const setInput = (input: string) => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), input } }));
const setMentions = (mentions: ReadonlySet<string>) => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), mentions: [...mentions] } }));
const setDispatchMode = (mode: "parallel" | "relay") => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), mode } }));
Expand Down Expand Up @@ -543,7 +551,7 @@ export function GroupsPanel({
const ref = selectedRefRef.current;
const trimmed = input.trim();
if (
ref === null || trimmed.length === 0 || mentions.size === 0 || !mentionsReady ||
ref === null || trimmed.length === 0 || mentions.size === 0 || !mentionsReady || invalidRelaySteps ||
sendingGroupsRef.current.has(ref)
) return;
const requestEngine = mentionEngines[mentionedPositionIds[0]!] ?? engine;
Expand All @@ -560,6 +568,7 @@ export function GroupsPanel({
engines: mentionEngines,
mentions: mentionedPositionIds,
mode: dispatchMode,
...(dispatchMode === "relay" && relaySteps.length ? { relaySteps } : {}),
});
if (!isCurrent()) return;
if (res.status !== 202) {
Expand All @@ -572,7 +581,7 @@ export function GroupsPanel({
spawns: Array<{ turnId: string; positionId: string; engine?: TurnEngine }>;
};
onSpawnRuns(ref, body.messageId, body.spawns, trimmed, requestEngine);
setDrafts((current) => current[ref]?.input === input ? { ...current, [ref]: { ...current[ref]!, input: "", mentions: [] } } : current);
setDrafts((current) => current[ref] === draft ? { ...current, [ref]: { ...current[ref]!, input: "", mentions: [], instructions: {} } } : current);
void loadTimeline(ref);
} catch {
if (isCurrent() && selectedRefRef.current === ref) setPanelError(t("grp.turnFailOffline"));
Expand All @@ -585,6 +594,9 @@ export function GroupsPanel({
}, [
captureScope,
dispatchMode,
draft,
invalidRelaySteps,
relaySteps,
engine,
input,
loadTimeline,
Expand Down Expand Up @@ -946,6 +958,7 @@ export function GroupsPanel({
)}
</header>
{isLive || !["group_relay_blocked", "group_relay_stopped"].includes(turn.errorCode ?? "") ? <ProgressTrail turn={turn} onOpenResource={onOpenResource} /> : null}
{"relayHandoff" in turn && turn.relayHandoff ? <RelayHandoffDetails snapshot={turn.relayHandoff} positionName={displayPositionName} /> : null}
{turn.output ? (
<GroupBubbleExpand
summaryClassName="owb-turn__output owb-clamp-2"
Expand Down Expand Up @@ -1042,6 +1055,20 @@ export function GroupsPanel({
options={[{ value: "parallel", label: t("grp.modeParallel") }, { value: "relay", label: t("grp.modeRelay") }]} />
</label>
{dispatchMode === "relay" ? <p className="owb-turn-composer__hint">{t("grp.relayOrder", { order: [...mentions].map(displayPositionName).join(" → ") })}</p> : null}
{dispatchMode === "relay" && mentions.size > 0 ? <details className="owb-relay-instructions">
<summary>{t("grp.relayInstructions")}</summary>
<div className="owb-relay-instructions__fields">
{mentionedPositionIds.map((positionId, index) => <label key={positionId}>
<span>{index + 1}. {displayPositionName(positionId)}</span>
<Input.TextArea value={draft.instructions[positionId] ?? ""} rows={2}
aria-label={t("grp.relayInstructionFor", { name: displayPositionName(positionId) })}
placeholder={t("grp.relayInstructionPh")} disabled={sending}
onChange={event => setStepInstruction(positionId, event.target.value)} />
<small>{instructionBytes((draft.instructions[positionId] ?? "").trim())} / {RELAY_INSTRUCTION_BYTES} bytes</small>
</label>)}
</div>
</details> : null}
{invalidRelaySteps ? <p role="alert">{t("grp.relayInstructionLimit")}</p> : null}
<div className="owb-turn-composer__surface">
<Input.TextArea
value={input}
Expand All @@ -1064,7 +1091,7 @@ export function GroupsPanel({
<AntButton
type="primary"
htmlType="submit"
disabled={sending || input.trim().length === 0 || mentions.size === 0 || !mentionsReady}
disabled={sending || input.trim().length === 0 || mentions.size === 0 || !mentionsReady || invalidRelaySteps}
aria-label={t("grp.send")}
icon={<ArrowUp aria-hidden="true" size={15} />}
/>
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/renderer/src/groups/RelayHandoffDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState } from "react";
import { useT } from "@roleweave/ui";
import type { RelayHandoffSnapshot } from "@roleweave/shared";

export function RelayHandoffDetails({ snapshot, positionName }: {
snapshot: RelayHandoffSnapshot;
positionName: (id: string) => string;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const p = snapshot.payload;
return <details className="owb-relay-handoff" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
<summary>{t("grp.handoffTitle")} · {t("grp.handoffStep", { number: p.step.number, total: p.step.total })} · {positionName(p.step.positionId)}</summary>
{open ? <div className="owb-relay-handoff__body">
<p className="owb-muted">{t("grp.handoffSnapshot")} <time dateTime={p.generatedAt}>{new Date(p.generatedAt).toLocaleString()}</time></p>
<dl>
<dt>{t("grp.handoffTask")}</dt><dd>{p.taskExcerpt}</dd>
<dt>{t("grp.handoffInstruction")}</dt><dd>{p.instruction ?? t("grp.handoffUnspecified")}</dd>
<dt>{t("grp.handoffNext")}</dt><dd>{p.step.nextPositionId ? positionName(p.step.nextPositionId) : t("grp.handoffLast")}</dd>
</dl>
<p>{t("grp.handoffCompletedNotice")}</p>
{p.taskExcerptTruncated ? <p>{t("grp.handoffTaskTrimmed")}</p> : null}
{p.redacted ? <p>{t("grp.handoffRedacted")}</p> : null}
{p.omittedSourceCount > 0 ? <p>{t("grp.handoffOmitted", { count: p.omittedSourceCount })}</p> : null}
{p.sources.length === 0 ? <p className="owb-muted">{t("grp.handoffFirst")}</p> : p.sources.map(source => <section key={source.turnId}>
<strong>{positionName(source.positionId)}</strong>
<p className="owb-muted">{t("grp.handoffSource")} <code>{source.turnId}</code> · <time dateTime={source.sourceUpdatedAt}>{new Date(source.sourceUpdatedAt).toLocaleString()}</time></p>
<pre>{source.outputExcerpt || t("grp.handoffEmpty")}</pre>
{source.truncated ? <p>{t("grp.handoffSourceTrimmed")}</p> : null}
</section>)}
</div> : null}
</details>;
}
15 changes: 15 additions & 0 deletions apps/desktop/renderer/src/groups/relay-instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { RelayStepInstruction } from "@roleweave/shared";
// Mirror the public request limits; contract parity is covered by renderer tests.
export const RELAY_INSTRUCTION_BYTES = 4096;
export const RELAY_INSTRUCTIONS_BYTES = 32768;
export const instructionBytes = (text: string): number => new TextEncoder().encode(text).length;
export function selectedRelaySteps(mentions: readonly string[], instructions: Record<string, string>): RelayStepInstruction[] {
return mentions.flatMap(positionId => {
const instruction = (instructions[positionId] ?? "").trim();
return instruction ? [{ positionId, instruction }] : [];
});
}
export function relayStepsTooLarge(steps: readonly RelayStepInstruction[]): boolean {
return steps.some(step => instructionBytes(step.instruction) > RELAY_INSTRUCTION_BYTES)
|| steps.reduce((sum, step) => sum + instructionBytes(step.instruction), 0) > RELAY_INSTRUCTIONS_BYTES;
}
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/owb.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export interface OwbBridge {
engines?: Record<string, TurnEngine>;
mentions: string[];
mode?: "parallel" | "relay";
relaySteps?: import("@roleweave/shared").RelayStepInstruction[];
}): Promise<OwbApiResponse<{
conversationRef: string;
messageId: string;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/turns/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function adaptTurnRecord(
...(pendingApproval !== undefined ? { approvalRequest: pendingApproval } : {}),
envelopeDigest: record.envelopeDigest,
...(record.threadContext !== undefined ? { threadContext: record.threadContext } : {}),
...(record.relayHandoff !== undefined ? { relayHandoff: record.relayHandoff } : {}),
...(progress.length > 0 ? { progress } : {}),
...(trace && trace.length > 0 ? { trace } : {}),
...(usage !== undefined ? { totalTokens: usage } : {}),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/turns/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface TurnRecord {
errorCode?: string;
diagnostic?: string;
threadContext?: ApiTurnRecord["threadContext"];
relayHandoff?: ApiTurnRecord["relayHandoff"];
/** Engine runId when the server record carries one; keys live-stream dedupe. */
runId?: string;
/** Live engine-reported usage; present only on provisional live rows. */
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/renderer/test/groups-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -729,9 +729,11 @@ it("sends explicit relay in selected order and restores mode, outputs and blocke
pickSelectOption("选择要 @ 的成员", "Release Engineer");
pickSelectOption("选择要 @ 的成员", "Repo Owner");
expect(screen.getByText(/按选择顺序执行:Release Engineer → Repo Owner/)).toBeInTheDocument();
fireEvent.click(screen.getByText("本步要求(可选)"));
fireEvent.change(screen.getByRole("textbox", { name: "Repo Owner 的本步要求" }), { target: { value: "Verify the citations" } });
fireEvent.change(screen.getByRole("textbox", { name: "群聊消息" }), { target: { value: "next relay" } });
fireEvent.click(screen.getByRole("button", { name: "发送群消息" }));
await waitFor(() => expect(bridge.createGroupTurn).toHaveBeenCalledWith({ conversationRef: group.conversationRef, input: "next relay", engine: "qoder", engines: { "release-engineer": "qoder", "repo-owner": "qoder" }, mentions: ["release-engineer", "repo-owner"], mode: "relay" }));
await waitFor(() => expect(bridge.createGroupTurn).toHaveBeenCalledWith({ conversationRef: group.conversationRef, input: "next relay", engine: "qoder", engines: { "release-engineer": "qoder", "repo-owner": "qoder" }, mentions: ["release-engineer", "repo-owner"], mode: "relay", relaySteps: [{ positionId: "repo-owner", instruction: "Verify the citations" }] }));
});

it("shows relay STOP advice and requires an explicit owner confirmation (#469)", async () => {
Expand Down Expand Up @@ -886,3 +888,21 @@ it.each([["codex-local", "Codex"], ["workbuddy", "WorkBuddy"]] as const)("blocks
fireEvent.submit(screen.getByRole("textbox", { name: "群聊消息" }).closest("form")!);
expect(bridge.createGroupTurn).not.toHaveBeenCalled();
});


it("does not submit oversized step requirements or carry them into parallel mode", async () => {
const { bridge } = renderPanel({ createGroupTurn: vi.fn().mockResolvedValue({ status: 202, body: { conversationRef: group.conversationRef, messageId: "new-message", spawns: [] } }) });
await screen.findByRole("textbox", { name: "群聊消息" });
pickSelectOption("协作方式", "依次接力");
pickSelectOption("选择要 @ 的成员", "Repo Owner");
fireEvent.click(screen.getByText("本步要求(可选)"));
fireEvent.change(screen.getByRole("textbox", { name: "Repo Owner 的本步要求" }), { target: { value: "汉".repeat(1366) } });
fireEvent.change(screen.getByRole("textbox", { name: "群聊消息" }), { target: { value: "current task" } });
expect(screen.getByRole("button", { name: "发送群消息" })).toBeDisabled();
expect(screen.getByRole("alert")).toHaveTextContent("4 KiB");
fireEvent.mouseDown(screen.getByRole("combobox", { name: "协作方式" }));
fireEvent.click(await screen.findByText("并行处理", { selector: ".ant-select-item-option-content" }));
fireEvent.click(screen.getByRole("button", { name: "发送群消息" }));
await waitFor(() => expect(bridge.createGroupTurn).toHaveBeenCalled());
expect((bridge.createGroupTurn as ReturnType<typeof vi.fn>).mock.calls[0]![0]).not.toHaveProperty("relaySteps");
});
Loading
Loading