@saadnvd1/agent-os 0.3.723 → 0.3.728

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +1 -0
  2. package/app/api/loops/route.ts +17 -0
  3. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  4. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  5. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  6. package/app/api/tasks/[id]/loop/route.ts +72 -0
  7. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  8. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  9. package/app/api/tasks/[id]/mode/route.ts +29 -0
  10. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  11. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  12. package/app/api/tasks/[id]/report/route.ts +39 -0
  13. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  14. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  15. package/app/page.tsx +8 -0
  16. package/components/Analyst/PairChip.tsx +131 -0
  17. package/components/Analyst/PairSwitch.tsx +76 -0
  18. package/components/Analyst/ReviewCard.tsx +272 -0
  19. package/components/Analyst/SignalChips.tsx +39 -0
  20. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  21. package/components/Analyst/cardState.test.ts +109 -0
  22. package/components/Analyst/cardState.ts +95 -0
  23. package/components/Analyst/pair.ts +81 -0
  24. package/components/Chat/Code.tsx +3 -0
  25. package/components/Chat/Messages.tsx +6 -1
  26. package/components/Chat/itemContext.ts +14 -0
  27. package/components/Draft/DraftChips.tsx +10 -0
  28. package/components/Draft/DraftPanel.tsx +7 -0
  29. package/components/Pane/DesktopTabBar.tsx +6 -0
  30. package/components/Pane/MobileTabBar.tsx +17 -0
  31. package/components/Pane/MobileViewPicker.tsx +4 -0
  32. package/components/Pane/PaneMenu.test.ts +21 -0
  33. package/components/Pane/PaneMenu.tsx +16 -0
  34. package/components/Pane/PaneViewToggle.tsx +12 -0
  35. package/components/Pane/index.tsx +19 -0
  36. package/components/Report/DraftCard.tsx +89 -0
  37. package/components/Report/EstimateBar.tsx +51 -0
  38. package/components/Report/EvidenceSheet.tsx +98 -0
  39. package/components/Report/ModeChooser.tsx +43 -0
  40. package/components/Report/ReportView.tsx +289 -0
  41. package/components/SessionList/SessionRowMenu.tsx +6 -0
  42. package/components/Sessions/RolePill.tsx +7 -0
  43. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  44. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  45. package/components/Tasks/Loop/LoopView.tsx +350 -0
  46. package/components/Tasks/Loop/labels.ts +17 -0
  47. package/components/Tasks/TaskRow.tsx +2 -0
  48. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  49. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  50. package/components/ui/segmented.tsx +55 -0
  51. package/components/ui/status-pill.tsx +3 -0
  52. package/contexts/PaneContext.tsx +21 -1
  53. package/data/loops/index.ts +3 -0
  54. package/data/loops/keys.ts +4 -0
  55. package/data/loops/mutations.ts +68 -0
  56. package/data/loops/queries.ts +40 -0
  57. package/data/push/topics.ts +6 -0
  58. package/data/reports/index.ts +115 -0
  59. package/lib/analyst/forward.ts +1 -1
  60. package/lib/db/changes.ts +7 -0
  61. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  62. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  63. package/lib/done/index.ts +2 -0
  64. package/lib/drafts.ts +2 -0
  65. package/lib/loops/analyse.ts +46 -0
  66. package/lib/loops/analyst-prompt.ts +203 -0
  67. package/lib/loops/checkCriteria.ts +114 -0
  68. package/lib/loops/compactEvidence.ts +140 -0
  69. package/lib/loops/controls.ts +317 -0
  70. package/lib/loops/decideStop.ts +113 -0
  71. package/lib/loops/deps.ts +128 -0
  72. package/lib/loops/diffMap.ts +39 -0
  73. package/lib/loops/draftCriteria.ts +71 -0
  74. package/lib/loops/gatherEvidence.ts +265 -0
  75. package/lib/loops/hooks.ts +40 -0
  76. package/lib/loops/index.ts +7 -0
  77. package/lib/loops/route.ts +83 -0
  78. package/lib/loops/runCycle.ts +323 -0
  79. package/lib/loops/screenInstruction.ts +59 -0
  80. package/lib/loops/sendInstruction.ts +83 -0
  81. package/lib/loops/snapshot.ts +38 -0
  82. package/lib/loops/stopLoop.ts +86 -0
  83. package/lib/loops/store.ts +481 -0
  84. package/lib/loops/summary.ts +27 -0
  85. package/lib/loops/tickLoops.ts +141 -0
  86. package/lib/loops/types.ts +223 -0
  87. package/lib/loops/world.test-helpers.ts +223 -0
  88. package/lib/models/plan-step.ts +4 -1
  89. package/lib/orchestrator/digest.ts +2 -1
  90. package/lib/orchestrator/loop-guard.ts +5 -2
  91. package/lib/orchestrator/pr-wake.ts +4 -0
  92. package/lib/orchestrator/watcher.ts +8 -1
  93. package/lib/panes.ts +52 -0
  94. package/lib/plans/acceptance.ts +23 -0
  95. package/lib/reports/drafts.ts +87 -0
  96. package/lib/reports/end-hook.ts +17 -0
  97. package/lib/reports/estimate.ts +107 -0
  98. package/lib/reports/evidence/build.ts +193 -0
  99. package/lib/reports/evidence/gather.ts +229 -0
  100. package/lib/reports/fixtures.ts +17 -0
  101. package/lib/reports/generate.ts +159 -0
  102. package/lib/reports/hooks.ts +30 -0
  103. package/lib/reports/mode.ts +65 -0
  104. package/lib/reports/model.ts +115 -0
  105. package/lib/reports/read.ts +26 -0
  106. package/lib/reports/store.ts +180 -0
  107. package/lib/reports/template.ts +117 -0
  108. package/lib/reports/types.ts +135 -0
  109. package/lib/reports/validate.ts +142 -0
  110. package/lib/sidebar/row-menu.ts +9 -0
  111. package/lib/tasks/contract/routes.ts +5 -0
  112. package/lib/tasks/contract/types.ts +3 -0
  113. package/lib/tasks/finish.ts +2 -0
  114. package/lib/tasks/move-carry.ts +1 -0
  115. package/package.json +2 -2
  116. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { toast } from "sonner";
5
+ import { usePanes } from "@/contexts/PaneContext";
6
+ import {
7
+ useSessionAnalyst,
8
+ useStartAnalyst,
9
+ type StartAnalystInput,
10
+ } from "@/data/analyst";
11
+ import type { Session } from "@/lib/db";
12
+ import type { AnalystLinkView, PaneSession } from "@/lib/analyst/types";
13
+
14
+ // A chat's tmux name: `<agent>-<id>`, as every chat tab attaches.
15
+ export const paneTmux = (s: PaneSession) => `${s.agentType}-${s.id}`;
16
+
17
+ export type PairStatus = "paused" | "reviewing" | "recommendation" | "watching";
18
+
19
+ export function pairStatus(link: AnalystLinkView): PairStatus {
20
+ if (link.paused) return "paused";
21
+ if (link.pendingDigestId) return "reviewing";
22
+ if (link.recommendations.some((r) => r.state === "open"))
23
+ return "recommendation";
24
+ return "watching";
25
+ }
26
+
27
+ export const PAIR_STATUS_LABEL: Record<PairStatus, string> = {
28
+ paused: "Paused",
29
+ reviewing: "Reviewing",
30
+ recommendation: "Prompt ready",
31
+ watching: "Watching",
32
+ };
33
+
34
+ // The side of a pairing a session isn't.
35
+ export function partnerOf(link: AnalystLinkView, sessionId: string) {
36
+ return link.coder.id === sessionId ? link.analyst : link.coder;
37
+ }
38
+
39
+ // Starts an analyst for a chat and shows it beside the coder's pane: the
40
+ // one given, else one showing the coder, else the focused one.
41
+ export function useStartAnalystBeside() {
42
+ const start = useStartAnalyst();
43
+ const { openBeside, state, focusedPaneId } = usePanes();
44
+ const run = useCallback(
45
+ async (input: StartAnalystInput, paneId?: string) => {
46
+ try {
47
+ const link = await start.mutateAsync(input);
48
+ const showing = Object.entries(state.panes).find(([, p]) =>
49
+ p.tabs.some(
50
+ (t) => t.id === p.activeTabId && t.sessionId === link.coder.id
51
+ )
52
+ )?.[0];
53
+ openBeside(paneId ?? showing ?? focusedPaneId, {
54
+ sessionId: link.analyst.id,
55
+ tmux: paneTmux(link.analyst),
56
+ });
57
+ return link;
58
+ } catch (error) {
59
+ toast.error(error instanceof Error ? error.message : String(error));
60
+ return null;
61
+ }
62
+ },
63
+ [start, openBeside, state.panes, focusedPaneId]
64
+ );
65
+ return { run, isPending: start.isPending };
66
+ }
67
+
68
+ // Whether a session can be given an analyst here: a local chat that isn't
69
+ // an orchestrator, an analyst or already paired. The server checks again.
70
+ export function useCanAddAnalyst(session: Session | null | undefined) {
71
+ const chat = session?.view === "chat" ? session : null;
72
+ const paired = useSessionAnalyst(chat?.id).data;
73
+ return (
74
+ !!chat &&
75
+ !chat.role &&
76
+ !chat.archived_at &&
77
+ (chat.host_id || "local") === "local" &&
78
+ chat.agent_role !== "analyst" &&
79
+ !paired
80
+ );
81
+ }
@@ -12,6 +12,8 @@ import oneDark from "react-syntax-highlighter/dist/esm/styles/prism/one-dark";
12
12
  import oneLight from "react-syntax-highlighter/dist/esm/styles/prism/one-light";
13
13
  import { CopyButton } from "./CopyButton";
14
14
  import { Mermaid } from "./Mermaid";
15
+ import { ReviewCard } from "@/components/Analyst/ReviewCard";
16
+ import { REVIEW_FENCE } from "@/lib/analyst/parseReview";
15
17
 
16
18
  const ALIASES: Record<string, string> = {
17
19
  sh: "bash",
@@ -117,6 +119,7 @@ export function CodeBlock({
117
119
  language?: string;
118
120
  }) {
119
121
  if (language === "mermaid") return <Mermaid code={code} />;
122
+ if (language === REVIEW_FENCE) return <ReviewCard code={code} />;
120
123
  return (
121
124
  <div className="bg-foreground/[0.04] not-prose my-3 overflow-hidden rounded-xl">
122
125
  <div
@@ -27,6 +27,7 @@ import { openSessionLink } from "./session-link";
27
27
  import { UndoButton } from "./Undo";
28
28
  import { useRowState } from "./rowState";
29
29
  import { useBookmarkChat } from "@/components/Bookmarks/context";
30
+ import { ChatItemContext } from "./itemContext";
30
31
  import { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
31
32
  import {
32
33
  BookmarkButton,
@@ -299,7 +300,11 @@ export function AssistantMessage({
299
300
  <BookmarkPress itemId={item.id}>
300
301
  <div className="group">
301
302
  <div data-quotable className={cn(item.streaming && "streaming-cursor")}>
302
- <Markdown text={item.text} streaming={item.streaming} />
303
+ <ChatItemContext.Provider
304
+ value={{ itemId: item.id, streaming: !!item.streaming }}
305
+ >
306
+ <Markdown text={item.text} streaming={item.streaming} />
307
+ </ChatItemContext.Provider>
303
308
  </div>
304
309
  {!item.streaming && (
305
310
  <div className="-ml-1.5 flex items-center">
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ // The message a rendered block belongs to, for blocks that act on it (a
6
+ // review card finds its stored recommendation by the item's id).
7
+ export interface ChatItemRef {
8
+ itemId: string;
9
+ streaming: boolean;
10
+ }
11
+
12
+ export const ChatItemContext = createContext<ChatItemRef | null>(null);
13
+
14
+ export const useChatItem = () => useContext(ChatItemContext);
@@ -4,6 +4,7 @@ import { useEffect } from "react";
4
4
  import {
5
5
  Bot,
6
6
  Cpu,
7
+ Eye,
7
8
  FolderGit2,
8
9
  GitBranch,
9
10
  Flag,
@@ -252,6 +253,15 @@ export function DraftChips({
252
253
  onChange={(openPr) => onChange({ openPr })}
253
254
  />
254
255
  )}
256
+ {project && !draft.openPr && local && draft.agentRole !== "analyst" && (
257
+ <ChipToggle
258
+ icon={Eye}
259
+ label="With analyst"
260
+ on={!!draft.withAnalyst}
261
+ title="A second chat reviews each turn and drafts the next prompt for you to send"
262
+ onChange={(withAnalyst) => onChange({ withAnalyst })}
263
+ />
264
+ )}
255
265
  {project && draft.openPr && local && (
256
266
  <ChipMenu
257
267
  icon={Cpu}
@@ -23,6 +23,7 @@ import type { StartWarning } from "@/lib/claims/startWarning";
23
23
  import { usePanes } from "@/contexts/PaneContext";
24
24
  import { setPendingPrompt } from "@/stores/initialPrompt";
25
25
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
26
+ import { useStartAnalystBeside } from "@/components/Analyst/pair";
26
27
  import { DraftChips } from "./DraftChips";
27
28
  import { DraftHeading } from "./DraftHeading";
28
29
  import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
@@ -57,6 +58,7 @@ export function DraftPanel({
57
58
  discovers
58
59
  );
59
60
  const launch = useLaunchSession();
61
+ const startAnalyst = useStartAnalystBeside();
60
62
  const { workspace } = useSelectedWorkspace();
61
63
  const createTask = useCreateTask();
62
64
  const checkStart = useCheckStart();
@@ -172,6 +174,11 @@ export function DraftPanel({
172
174
  session.tmux_name ?? "",
173
175
  session.host_id
174
176
  );
177
+ if (draft.withAnalyst && project && draft.hostId === "local") {
178
+ if (session.view === "chat")
179
+ void startAnalyst.run({ coderSessionId: session.id }, paneId);
180
+ else toast("An analyst reviews chats: this session runs in a terminal");
181
+ }
175
182
  } catch (error) {
176
183
  if (error instanceof ModelUnavailableError) {
177
184
  setDown({ error, text, images, files });
@@ -16,6 +16,7 @@ import { PaneViewToggle } from "./PaneViewToggle";
16
16
  import { PaneMenu } from "./PaneMenu";
17
17
  import { HostBadge } from "@/components/Hosts/HostBadge";
18
18
  import { RolePill } from "@/components/Sessions/RolePill";
19
+ import { PairChip } from "@/components/Analyst/PairChip";
19
20
  import { usePaneBarSlots } from "./PaneBarSlots";
20
21
  import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
21
22
  import type { ViewMode } from "@/contexts/PaneContext";
@@ -37,6 +38,7 @@ interface DesktopTabBarProps {
37
38
  isFocused: boolean;
38
39
  isConductor: boolean;
39
40
  hasLog?: boolean;
41
+ hasReport?: boolean;
40
42
  workerCount: number;
41
43
  canSplit: boolean;
42
44
  canClose: boolean;
@@ -68,6 +70,7 @@ export function DesktopTabBar({
68
70
  isFocused,
69
71
  isConductor,
70
72
  hasLog,
73
+ hasReport,
71
74
  workerCount,
72
75
  canSplit,
73
76
  canClose,
@@ -163,6 +166,7 @@ export function DesktopTabBar({
163
166
 
164
167
  <div className="flex shrink-0 items-center gap-1.5">
165
168
  <RolePill session={session} paneId={paneId} />
169
+ <PairChip session={session} paneId={paneId} />
166
170
  <HostBadge hostId={session?.host_id} />
167
171
  <ContextMeter sessionId={session?.id} />
168
172
  <SessionPrStage sessionId={session?.id} />
@@ -182,6 +186,7 @@ export function DesktopTabBar({
182
186
  isLocalSession={isLocalSession}
183
187
  isConductor={isConductor}
184
188
  hasLog={hasLog}
189
+ hasReport={hasReport}
185
190
  workerCount={workerCount}
186
191
  showGit={showGit}
187
192
  gitDrawerOpen={gitDrawerOpen}
@@ -194,6 +199,7 @@ export function DesktopTabBar({
194
199
  <div className="flex items-center">
195
200
  <PaneMenu
196
201
  session={session}
202
+ paneId={paneId}
197
203
  bookmarksSessionId={bookmarksSessionId}
198
204
  canSplit={canSplit}
199
205
  canClose={canClose}
@@ -26,6 +26,7 @@ import {
26
26
  Share2,
27
27
  Download,
28
28
  TextSearch,
29
+ Eye,
29
30
  } from "lucide-react";
30
31
  import { cn } from "@/lib/utils";
31
32
  import { useAwayQuery } from "@/data/away";
@@ -36,6 +37,8 @@ import { RolePill } from "@/components/Sessions/RolePill";
36
37
  import { bookmarksUiActions } from "@/stores/bookmarksUi";
37
38
  import { shareUiActions } from "@/stores/shareUi";
38
39
  import { exportUiActions } from "@/stores/exportUi";
40
+ import { analystUiActions } from "@/stores/analystUi";
41
+ import { useCanAddAnalyst } from "@/components/Analyst/pair";
39
42
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
40
43
  import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
41
44
  import type { ViewMode } from "@/contexts/PaneContext";
@@ -50,6 +53,7 @@ interface MobileTabBarProps {
50
53
  viewMode: ViewMode;
51
54
  isConductor: boolean;
52
55
  hasLog?: boolean;
56
+ hasReport?: boolean;
53
57
  workerCount: number;
54
58
  // Local, and the folder is not known to be outside a git repository.
55
59
  showGit: boolean;
@@ -70,6 +74,7 @@ export function MobileTabBar({
70
74
  viewMode,
71
75
  isConductor,
72
76
  hasLog,
77
+ hasReport,
73
78
  workerCount,
74
79
  showGit,
75
80
  onMenuClick,
@@ -128,6 +133,7 @@ export function MobileTabBar({
128
133
  };
129
134
 
130
135
  const isLocalSession = !session?.host_id || session.host_id === "local";
136
+ const canAddAnalyst = useCanAddAnalyst(session);
131
137
 
132
138
  return (
133
139
  <div
@@ -230,6 +236,16 @@ export function MobileTabBar({
230
236
  <Download className="h-4 w-4" />
231
237
  Export for AI…
232
238
  </DropdownMenuItem>
239
+ {canAddAnalyst && (
240
+ <DropdownMenuItem
241
+ onSelect={() =>
242
+ analystUiActions.open(session.id, session.name)
243
+ }
244
+ >
245
+ <Eye className="h-4 w-4" />
246
+ Add analyst…
247
+ </DropdownMenuItem>
248
+ )}
233
249
  <DropdownMenuSeparator />
234
250
  </>
235
251
  )}
@@ -375,6 +391,7 @@ export function MobileTabBar({
375
391
  isLocalSession={isLocalSession}
376
392
  showGit={showGit}
377
393
  hasLog={hasLog}
394
+ hasReport={hasReport}
378
395
  isConductor={isConductor}
379
396
  workerCount={workerCount}
380
397
  />
@@ -7,6 +7,7 @@ import {
7
7
  GitBranch,
8
8
  Package,
9
9
  ScrollText,
10
+ ClipboardList,
10
11
  Terminal as TerminalIcon,
11
12
  Users,
12
13
  type LucideIcon,
@@ -31,6 +32,7 @@ export interface MobileViewAvailability {
31
32
  // Local, and the folder is not known to be outside a git repository.
32
33
  showGit: boolean;
33
34
  hasLog?: boolean;
35
+ hasReport?: boolean;
34
36
  isConductor: boolean;
35
37
  workerCount: number;
36
38
  }
@@ -40,6 +42,7 @@ export function mobileViews({
40
42
  isLocalSession,
41
43
  showGit,
42
44
  hasLog,
45
+ hasReport,
43
46
  isConductor,
44
47
  workerCount,
45
48
  }: MobileViewAvailability): MobileView[] {
@@ -48,6 +51,7 @@ export function mobileViews({
48
51
  isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
49
52
  showGit && { mode: "git", label: "Git", icon: GitBranch },
50
53
  { mode: "artifacts", label: "Artifacts", icon: Package },
54
+ !!hasReport && { mode: "report", label: "Report", icon: ClipboardList },
51
55
  !!hasLog && { mode: "log", label: "Log", icon: ScrollText },
52
56
  isConductor && {
53
57
  mode: "workers",
@@ -6,10 +6,12 @@ import type { Session } from "@/lib/db/types";
6
6
  import { bookmarksUi } from "@/stores/bookmarksUi";
7
7
  import { docsUi } from "@/stores/docsUi";
8
8
  import { paletteUi } from "@/stores/palette";
9
+ import { analystUi } from "@/stores/analystUi";
9
10
 
10
11
  const data = vi.hoisted(() => ({
11
12
  bookmarks: [] as unknown[],
12
13
  docs: null as { id: string } | null,
14
+ canAddAnalyst: false,
13
15
  }));
14
16
  vi.mock("@/data/bookmarks", () => ({
15
17
  useSessionBookmarks: () => ({ data: data.bookmarks }),
@@ -17,6 +19,9 @@ vi.mock("@/data/bookmarks", () => ({
17
19
  vi.mock("@/data/lumifyhub/docs", () => ({
18
20
  useDocsWorkspace: () => data.docs,
19
21
  }));
22
+ vi.mock("@/components/Analyst/pair", () => ({
23
+ useCanAddAnalyst: () => data.canAddAnalyst,
24
+ }));
20
25
  vi.mock("@/components/Tasks/MoveMenuItems", () => ({
21
26
  MoveMenuItems: () => null,
22
27
  }));
@@ -60,6 +65,8 @@ describe("PaneMenu", () => {
60
65
  root = createRoot(host);
61
66
  data.bookmarks = [];
62
67
  data.docs = null;
68
+ data.canAddAnalyst = false;
69
+ analystUi.open = false;
63
70
  paletteUi.open = false;
64
71
  bookmarksUi.chatSessionId = null;
65
72
  docsUi.workspaceId = null;
@@ -73,6 +80,7 @@ describe("PaneMenu", () => {
73
80
  root.render(
74
81
  createElement(PaneMenu, {
75
82
  session,
83
+ paneId: "pane-1",
76
84
  bookmarksSessionId,
77
85
  canSplit: true,
78
86
  canClose: true,
@@ -115,4 +123,17 @@ describe("PaneMenu", () => {
115
123
  act(() => item("Docs")?.click());
116
124
  expect(docsUi.workspaceId).toBe("w1");
117
125
  });
126
+
127
+ it("offers Add analyst only when the chat can have one, beside this pane", () => {
128
+ render();
129
+ expect(item("Add analyst")).toBeUndefined();
130
+ data.canAddAnalyst = true;
131
+ render();
132
+ act(() => item("Add analyst")?.click());
133
+ expect(analystUi).toMatchObject({
134
+ open: true,
135
+ sessionId: "s1",
136
+ paneId: "pane-1",
137
+ });
138
+ });
118
139
  });
@@ -12,6 +12,7 @@ import {
12
12
  SplitSquareHorizontal,
13
13
  SplitSquareVertical,
14
14
  Star,
15
+ Eye,
15
16
  Unplug,
16
17
  X,
17
18
  } from "lucide-react";
@@ -29,6 +30,8 @@ import { copySessionLink, writeClipboard } from "@/hooks/useCopyToClipboard";
29
30
  import type { Session } from "@/lib/db";
30
31
  import { shareUiActions } from "@/stores/shareUi";
31
32
  import { exportUiActions } from "@/stores/exportUi";
33
+ import { analystUiActions } from "@/stores/analystUi";
34
+ import { useCanAddAnalyst } from "@/components/Analyst/pair";
32
35
  import { useSessionBookmarks } from "@/data/bookmarks";
33
36
  import { useDocsWorkspace } from "@/data/lumifyhub/docs";
34
37
  import { bookmarksUiActions } from "@/stores/bookmarksUi";
@@ -38,6 +41,7 @@ import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
38
41
 
39
42
  interface PaneMenuProps {
40
43
  session: Session | null | undefined;
44
+ paneId?: string;
41
45
  // The chat on show, for its bookmarks; null for a terminal or another view.
42
46
  bookmarksSessionId?: string | null;
43
47
  canSplit: boolean;
@@ -53,6 +57,7 @@ interface PaneMenuProps {
53
57
  // the bar.
54
58
  export function PaneMenu({
55
59
  session,
60
+ paneId,
56
61
  bookmarksSessionId,
57
62
  canSplit,
58
63
  canClose,
@@ -64,6 +69,7 @@ export function PaneMenu({
64
69
  }: PaneMenuProps) {
65
70
  const { data: bookmarks = [] } = useSessionBookmarks(bookmarksSessionId);
66
71
  const docs = useDocsWorkspace(session?.project_id);
72
+ const canAddAnalyst = useCanAddAnalyst(session);
67
73
  const copyId = async (id: string) => {
68
74
  try {
69
75
  await writeClipboard(id);
@@ -127,6 +133,16 @@ export function PaneMenu({
127
133
  Export for AI…
128
134
  </DropdownMenuItem>
129
135
  )}
136
+ {canAddAnalyst && session && (
137
+ <DropdownMenuItem
138
+ onSelect={() =>
139
+ analystUiActions.open(session.id, session.name, paneId)
140
+ }
141
+ >
142
+ <Eye className="h-4 w-4" />
143
+ Add analyst…
144
+ </DropdownMenuItem>
145
+ )}
130
146
  <DropdownMenuSeparator />
131
147
  <MoveMenuItems
132
148
  session={session}
@@ -6,6 +6,7 @@ import {
6
6
  Home,
7
7
  Package,
8
8
  ScrollText,
9
+ ClipboardList,
9
10
  Users,
10
11
  } from "lucide-react";
11
12
  import {
@@ -27,6 +28,7 @@ interface PaneViewToggleProps {
27
28
  isConductor: boolean;
28
29
  // The task merged and has a log.
29
30
  hasLog?: boolean;
31
+ hasReport?: boolean;
30
32
  workerCount: number;
31
33
  gitDrawerOpen: boolean;
32
34
  shellDrawerOpen: boolean;
@@ -85,6 +87,7 @@ export function PaneViewToggle({
85
87
  showGit,
86
88
  isConductor,
87
89
  hasLog,
90
+ hasReport,
88
91
  workerCount,
89
92
  gitDrawerOpen,
90
93
  shellDrawerOpen,
@@ -138,6 +141,15 @@ export function PaneViewToggle({
138
141
  <Package className="h-3.5 w-3.5" />
139
142
  </ToggleButton>
140
143
  )}
144
+ {hasReport && (
145
+ <ToggleButton
146
+ label="Report"
147
+ active={viewMode === "report"}
148
+ onClick={() => onViewModeChange("report")}
149
+ >
150
+ <ClipboardList className="h-3.5 w-3.5" />
151
+ </ToggleButton>
152
+ )}
141
153
  {hasLog && (
142
154
  <ToggleButton
143
155
  label="Log"
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
16
16
  import { ConductorPanel } from "@/components/ConductorPanel";
17
17
  import { useFileEditor } from "@/hooks/useFileEditor";
18
18
  import { MobileTabBar } from "./MobileTabBar";
19
+ import { PairSwitch } from "@/components/Analyst/PairSwitch";
19
20
  import { openFind } from "@/components/Chat/useFind";
20
21
  import { DesktopTabBar } from "./DesktopTabBar";
21
22
  import {
@@ -46,6 +47,10 @@ const ArtifactsPanel = dynamic(
46
47
  ),
47
48
  { ssr: false }
48
49
  );
50
+ const ReportView = dynamic(
51
+ () => import("@/components/Report/ReportView").then((mod) => mod.ReportView),
52
+ { ssr: false }
53
+ );
49
54
  const LogDocument = dynamic(
50
55
  () =>
51
56
  import("@/components/SessionLogs/LogDocument").then(
@@ -215,6 +220,7 @@ export const Pane = memo(function Pane({
215
220
  !!session?.task_prompt &&
216
221
  (session.task_status === "merged" || session.pr_status === "merged");
217
222
  const hasLog = !!useTaskLogQuery(session?.id, merged).data;
223
+ const hasReport = !!session?.task_prompt;
218
224
 
219
225
  // Get current project and its repositories
220
226
  const currentProject = useMemo(() => {
@@ -471,6 +477,7 @@ export const Pane = memo(function Pane({
471
477
  viewMode={viewMode}
472
478
  isConductor={isConductor}
473
479
  hasLog={hasLog}
480
+ hasReport={hasReport}
474
481
  workerCount={workerCount}
475
482
  showGit={showGit}
476
483
  onMenuClick={onMenuClick}
@@ -494,6 +501,7 @@ export const Pane = memo(function Pane({
494
501
  isFocused={isFocused}
495
502
  isConductor={isConductor}
496
503
  hasLog={hasLog}
504
+ hasReport={hasReport}
497
505
  workerCount={workerCount}
498
506
  canSplit={canSplit}
499
507
  canClose={canClose}
@@ -514,6 +522,7 @@ export const Pane = memo(function Pane({
514
522
  bookmarksSessionId={bookmarksSessionId}
515
523
  />
516
524
  )}
525
+ {isMobile && <PairSwitch session={session} paneId={paneId} />}
517
526
 
518
527
  {/* Content Area - Mobile: simple flex, Desktop: resizable panels */}
519
528
  {isMobile ? (
@@ -558,6 +567,11 @@ export const Pane = memo(function Pane({
558
567
  </div>
559
568
  )}
560
569
 
570
+ {/* Report */}
571
+ {viewMode === "report" && session && (
572
+ <ReportView sessionId={session.id} name={session.name} />
573
+ )}
574
+
561
575
  {/* Log */}
562
576
  {viewMode === "log" && session && (
563
577
  <LogDocument sessionId={session.id} />
@@ -629,6 +643,11 @@ export const Pane = memo(function Pane({
629
643
  </div>
630
644
  )}
631
645
 
646
+ {/* Report */}
647
+ {viewMode === "report" && session && (
648
+ <ReportView sessionId={session.id} name={session.name} />
649
+ )}
650
+
632
651
  {/* Log */}
633
652
  {viewMode === "log" && session && (
634
653
  <LogDocument sessionId={session.id} />
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Textarea } from "@/components/ui/textarea";
6
+ import { useReportDraft } from "@/data/reports";
7
+ import type { NextPromptDraft } from "@/lib/reports/types";
8
+
9
+ // The analyst's drafted next prompt, shown in full. Approve & send is the one
10
+ // click; nothing is sent without it.
11
+ export function DraftCard({
12
+ sessionId,
13
+ draft,
14
+ }: {
15
+ sessionId: string;
16
+ draft: NextPromptDraft;
17
+ }) {
18
+ const act = useReportDraft(sessionId);
19
+ const [editing, setEditing] = useState(false);
20
+ const [text, setText] = useState(draft.text);
21
+ const ref = { source: draft.source, id: draft.id, linkId: draft.linkId };
22
+ const decided = draft.status !== "pending";
23
+
24
+ if (decided)
25
+ return (
26
+ <p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-sm">
27
+ {draft.status === "sent"
28
+ ? "Sent to the coding agent."
29
+ : draft.status === "dismissed"
30
+ ? "Draft dismissed."
31
+ : "A newer report replaced this draft."}
32
+ </p>
33
+ );
34
+
35
+ return (
36
+ <section
37
+ aria-label="Drafted next prompt"
38
+ className="bg-card space-y-3 rounded-xl p-4 shadow-sm"
39
+ >
40
+ <h3 className="text-sm font-semibold">Drafted next prompt</h3>
41
+ {editing ? (
42
+ <Textarea
43
+ value={text}
44
+ onChange={(e) => setText(e.target.value)}
45
+ rows={6}
46
+ aria-label="Edit the prompt"
47
+ />
48
+ ) : (
49
+ <p className="bg-muted/50 rounded-lg p-3 text-sm whitespace-pre-wrap">
50
+ {draft.text}
51
+ </p>
52
+ )}
53
+ {act.isError && (
54
+ <p className="text-destructive text-sm">{act.error.message}</p>
55
+ )}
56
+ <div className="flex flex-wrap gap-2">
57
+ <Button
58
+ className="min-h-11 flex-1"
59
+ disabled={act.isPending || !text.trim()}
60
+ onClick={() =>
61
+ act.mutate({
62
+ ...ref,
63
+ action: "send",
64
+ text: editing ? text : undefined,
65
+ })
66
+ }
67
+ >
68
+ Approve &amp; send
69
+ </Button>
70
+ <Button
71
+ variant="secondary"
72
+ className="min-h-11"
73
+ disabled={act.isPending}
74
+ onClick={() => setEditing((e) => !e)}
75
+ >
76
+ {editing ? "Cancel edit" : "Edit"}
77
+ </Button>
78
+ <Button
79
+ variant="ghost"
80
+ className="min-h-11"
81
+ disabled={act.isPending}
82
+ onClick={() => act.mutate({ ...ref, action: "dismiss" })}
83
+ >
84
+ Dismiss
85
+ </Button>
86
+ </div>
87
+ </section>
88
+ );
89
+ }