@saadnvd1/agent-os 0.3.502 → 0.3.505

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 (46) hide show
  1. package/README.md +47 -0
  2. package/app/api/projects/[id]/session-logs/route.ts +39 -0
  3. package/app/api/session-logs/[id]/route.ts +18 -0
  4. package/components/Pane/DesktopTabBar.tsx +6 -1
  5. package/components/Pane/MobileTabBar.tsx +14 -1
  6. package/components/Pane/PaneViewToggle.tsx +14 -2
  7. package/components/Pane/index.tsx +28 -1
  8. package/components/PrStageBadge.tsx +78 -0
  9. package/components/Projects/ProjectSettingsDialog.tsx +11 -0
  10. package/components/SessionList/LiveLine.tsx +75 -0
  11. package/components/SessionList/RowParts.tsx +1 -7
  12. package/components/SessionList/RowShell.tsx +1 -1
  13. package/components/SessionList/RowSubtitle.tsx +4 -0
  14. package/components/SessionList/SessionList.types.ts +2 -0
  15. package/components/SessionList/SessionRow.tsx +9 -1
  16. package/components/SessionLogs/LogDocument.tsx +29 -0
  17. package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
  18. package/components/views/types.ts +2 -0
  19. package/contexts/PaneContext.tsx +1 -1
  20. package/data/session-logs/index.ts +73 -0
  21. package/lib/db/migrations.ts +24 -0
  22. package/lib/done/index.ts +2 -0
  23. package/lib/orchestrator/armed.ts +2 -0
  24. package/lib/orchestrator/brief.ts +1 -0
  25. package/lib/orchestrator/checks.ts +2 -0
  26. package/lib/orchestrator/facts.ts +1 -0
  27. package/lib/orchestrator/logs.ts +81 -0
  28. package/lib/orchestrator/serve.ts +5 -0
  29. package/lib/orchestrator/task-state.ts +30 -7
  30. package/lib/orchestrator/tool-names.ts +1 -0
  31. package/lib/orchestrator/tool-schemas.ts +13 -0
  32. package/lib/orchestrator/tools.ts +1 -0
  33. package/lib/session-logs/build.ts +253 -0
  34. package/lib/session-logs/folder.ts +145 -0
  35. package/lib/session-logs/gather.ts +301 -0
  36. package/lib/session-logs/index.ts +178 -0
  37. package/lib/session-logs/store.ts +105 -0
  38. package/lib/sidebar/live-status.ts +52 -0
  39. package/lib/sidebar/shelves.ts +5 -0
  40. package/lib/status/collect.ts +6 -0
  41. package/lib/status/pr-stages.ts +55 -0
  42. package/lib/tasks/finish.ts +3 -0
  43. package/lib/tasks/pr-stage.ts +106 -0
  44. package/lib/tasks/session.ts +17 -0
  45. package/package.json +1 -1
  46. package/scripts/backfill-session-logs.ts +70 -0
package/README.md CHANGED
@@ -1084,6 +1084,53 @@ to each idle or stopped session, and `aos done --all-idle` does the same
1084
1084
  sweep. A clean-up never merges: open PRs are listed for their own done.
1085
1085
  `aos done` reaches only sessions in the caller's workspace (or project).
1086
1086
 
1087
+ ### Session logs
1088
+
1089
+ When a task merges, AgentOS writes a log of it. It costs no model tokens: the
1090
+ log is a fixed template filled from records AgentOS already holds, and nothing
1091
+ that makes it can call a model (a test holds it to that). Dropped tasks get
1092
+ none.
1093
+
1094
+ A log holds:
1095
+
1096
+ - **Header:** task name and id, workspace, project, branch, PR number and
1097
+ link, merge commit, start, merge time and duration.
1098
+ - **Brief:** the task's prompt and every scope change recorded on it, in
1099
+ order.
1100
+ - **Plan:** `.agentos/plan.md`, read at the merge before cleanup removes the
1101
+ worktree. A task with no plan says so.
1102
+ - **Timeline:** PR opened, gate failures per commit (CI with the failing
1103
+ check's name, review, scope), each review and scope verdict per commit with
1104
+ its text trimmed, escalations to you and your answers, and the merge.
1105
+ - **Usage:** tokens, estimated cost and model per role, from the usage ledger.
1106
+ - **Outcome:** the task agent's last message, verbatim.
1107
+ - **Links:** the PR and the session.
1108
+
1109
+ Text from tasks, CI and reviewers is untrusted. It is written inside fences
1110
+ or code spans, so it can't add headings or links to the document, and
1111
+ credential-shaped strings are redacted.
1112
+
1113
+ The database is the record: `session_logs`, one row per task, never deleted.
1114
+ Each log is also written to
1115
+ `~/.agent-os/session-logs/<workspace>/<project>/YYYY-MM-DD-<task>.md`. A
1116
+ project's settings have a **Session log folder** (an absolute or `~` path) to
1117
+ put its files elsewhere; a folder inside the project's repository is refused,
1118
+ because repositories can be public, and the files are never written into one.
1119
+ The same dialog lists the project's logs, newest first, and a merged task's
1120
+ view has a **Log** tab. The orchestrator reads them with its `logs` tool: the
1121
+ latest headers of a workspace or project, or one log in full. It only reads.
1122
+
1123
+ The log is made once per task, in the background, from wherever the merge
1124
+ happens: a sign-off, Done, a stack landing, or a merge seen on GitHub. A
1125
+ failure is logged and never reaches the merge. For tasks that merged before
1126
+ this existed:
1127
+
1128
+ ```bash
1129
+ npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry]
1130
+ ```
1131
+
1132
+ Their plan files are gone, so those logs say "plan not kept".
1133
+
1087
1134
  ### Orchestrator
1088
1135
 
1089
1136
  Each workspace has one standing orchestrator, pinned at the top of its
@@ -0,0 +1,39 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getProject } from "@/lib/projects";
3
+ import { DEFAULT_ROOT } from "@/lib/session-logs/folder";
4
+ import { listLogs, logFolder, setLogFolder } from "@/lib/session-logs";
5
+
6
+ interface RouteParams {
7
+ params: Promise<{ id: string }>;
8
+ }
9
+
10
+ function state(id: string) {
11
+ return {
12
+ folder: logFolder(id),
13
+ defaultFolder: DEFAULT_ROOT(),
14
+ logs: listLogs({ projectId: id }, 100),
15
+ };
16
+ }
17
+
18
+ // The project's merged-task logs, newest first, and its log folder setting.
19
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
20
+ const { id } = await params;
21
+ if (!getProject(id))
22
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
23
+ return NextResponse.json(state(id));
24
+ }
25
+
26
+ // A folder inside the project's repository is refused with the reason.
27
+ export async function PUT(request: NextRequest, { params }: RouteParams) {
28
+ const { id } = await params;
29
+ if (!getProject(id))
30
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
31
+ try {
32
+ const { folder } = await request.json();
33
+ setLogFolder(id, typeof folder === "string" ? folder : null);
34
+ return NextResponse.json(state(id));
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return NextResponse.json({ error: message }, { status: 400 });
38
+ }
39
+ }
@@ -0,0 +1,18 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getLog, headerOf } from "@/lib/session-logs";
3
+
4
+ interface RouteParams {
5
+ params: Promise<{ id: string }>;
6
+ }
7
+
8
+ // One task's log: its header fields and the markdown.
9
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
10
+ const { id } = await params;
11
+ const row = getLog(id);
12
+ if (!row)
13
+ return NextResponse.json(
14
+ { error: "No log for this task" },
15
+ { status: 404 }
16
+ );
17
+ return NextResponse.json({ header: headerOf(row), markdown: row.markdown });
18
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SessionPrStage } from "@/components/PrStageBadge";
3
4
  import { ViewSwitch } from "@/components/Chat/ViewSwitch";
4
5
  import { ContextMeter } from "@/components/Chat/ContextMeter";
5
6
  import { Button } from "@/components/ui/button";
@@ -16,7 +17,7 @@ import { PaneMenu } from "./PaneMenu";
16
17
  import { HostBadge } from "@/components/Hosts/HostBadge";
17
18
  import { usePaneBarSlots } from "./PaneBarSlots";
18
19
 
19
- type ViewMode = "terminal" | "files" | "git" | "workers";
20
+ type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
20
21
 
21
22
  interface Tab {
22
23
  id: string;
@@ -34,6 +35,7 @@ interface DesktopTabBarProps {
34
35
  viewMode: ViewMode;
35
36
  isFocused: boolean;
36
37
  isConductor: boolean;
38
+ hasLog?: boolean;
37
39
  workerCount: number;
38
40
  canSplit: boolean;
39
41
  canClose: boolean;
@@ -61,6 +63,7 @@ export function DesktopTabBar({
61
63
  viewMode,
62
64
  isFocused,
63
65
  isConductor,
66
+ hasLog,
64
67
  workerCount,
65
68
  canSplit,
66
69
  canClose,
@@ -155,12 +158,14 @@ export function DesktopTabBar({
155
158
  <div className="flex shrink-0 items-center gap-1.5">
156
159
  <HostBadge hostId={session?.host_id} />
157
160
  <ContextMeter sessionId={session?.id} />
161
+ <SessionPrStage sessionId={session?.id} />
158
162
  <ViewSwitch session={session} />
159
163
  {session?.working_directory && (
160
164
  <PaneViewToggle
161
165
  viewMode={viewMode}
162
166
  isLocalSession={isLocalSession}
163
167
  isConductor={isConductor}
168
+ hasLog={hasLog}
164
169
  workerCount={workerCount}
165
170
  gitDrawerOpen={gitDrawerOpen}
166
171
  shellDrawerOpen={shellDrawerOpen}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SessionPrStage } from "@/components/PrStageBadge";
3
4
  import { ViewSwitch } from "@/components/Chat/ViewSwitch";
4
5
  import { paletteActions } from "@/stores/palette";
5
6
  import { newDraft } from "@/stores/drafts";
@@ -25,6 +26,7 @@ import {
25
26
  Terminal as TerminalIcon,
26
27
  TextSearch,
27
28
  Users,
29
+ ScrollText,
28
30
  } from "lucide-react";
29
31
  import { cn } from "@/lib/utils";
30
32
  import { useAwayQuery } from "@/data/away";
@@ -34,7 +36,7 @@ import type { LucideIcon } from "lucide-react";
34
36
  import { HostBadge } from "@/components/Hosts/HostBadge";
35
37
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
36
38
 
37
- type ViewMode = "terminal" | "files" | "git" | "workers";
39
+ type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
38
40
 
39
41
  interface ViewModeButtonProps {
40
42
  mode: ViewMode;
@@ -79,6 +81,7 @@ interface MobileTabBarProps {
79
81
  projects: Project[];
80
82
  viewMode: ViewMode;
81
83
  isConductor: boolean;
84
+ hasLog?: boolean;
82
85
  workerCount: number;
83
86
  onMenuClick?: () => void;
84
87
  onViewModeChange: (mode: ViewMode) => void;
@@ -94,6 +97,7 @@ export function MobileTabBar({
94
97
  projects,
95
98
  viewMode,
96
99
  isConductor,
100
+ hasLog,
97
101
  workerCount,
98
102
  onMenuClick,
99
103
  onViewModeChange,
@@ -317,6 +321,7 @@ export function MobileTabBar({
317
321
  >
318
322
  <Plus className="h-4 w-4" />
319
323
  </Button>
324
+ <SessionPrStage sessionId={session?.id} />
320
325
  <ViewSwitch session={session} />
321
326
 
322
327
  {/* View mode toggle */}
@@ -344,6 +349,14 @@ export function MobileTabBar({
344
349
  onClick={onViewModeChange}
345
350
  />
346
351
  )}
352
+ {hasLog && (
353
+ <ViewModeButton
354
+ mode="log"
355
+ currentMode={viewMode}
356
+ icon={ScrollText}
357
+ onClick={onViewModeChange}
358
+ />
359
+ )}
347
360
  {isConductor && (
348
361
  <ViewModeButton
349
362
  mode="workers"
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { FolderOpen, GitBranch, Home, Users } from "lucide-react";
3
+ import { FolderOpen, GitBranch, Home, ScrollText, Users } from "lucide-react";
4
4
  import {
5
5
  Tooltip,
6
6
  TooltipContent,
@@ -8,12 +8,14 @@ import {
8
8
  } from "@/components/ui/tooltip";
9
9
  import { cn } from "@/lib/utils";
10
10
 
11
- type ViewMode = "terminal" | "files" | "git" | "workers";
11
+ type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
12
12
 
13
13
  interface PaneViewToggleProps {
14
14
  viewMode: ViewMode;
15
15
  isLocalSession: boolean;
16
16
  isConductor: boolean;
17
+ // The task merged and has a log.
18
+ hasLog?: boolean;
17
19
  workerCount: number;
18
20
  gitDrawerOpen: boolean;
19
21
  shellDrawerOpen: boolean;
@@ -68,6 +70,7 @@ export function PaneViewToggle({
68
70
  viewMode,
69
71
  isLocalSession,
70
72
  isConductor,
73
+ hasLog,
71
74
  workerCount,
72
75
  gitDrawerOpen,
73
76
  shellDrawerOpen,
@@ -110,6 +113,15 @@ export function PaneViewToggle({
110
113
  </ToggleButton>
111
114
  </>
112
115
  )}
116
+ {hasLog && (
117
+ <ToggleButton
118
+ label="Log"
119
+ active={viewMode === "log"}
120
+ onClick={() => onViewModeChange("log")}
121
+ >
122
+ <ScrollText className="h-3.5 w-3.5" />
123
+ </ToggleButton>
124
+ )}
113
125
  {isConductor && (
114
126
  <ToggleButton
115
127
  label="Workers"
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTaskLogQuery } from "@/data/session-logs";
3
4
  import { SetupCard } from "@/components/Chat/SetupCard";
4
5
  import type { TabData } from "@/lib/panes";
5
6
  import { useRef, useCallback, useEffect, memo, useMemo } from "react";
@@ -34,6 +35,14 @@ import { useDemoMode } from "@/data/demo";
34
35
  import { DemoNotice } from "@/components/DemoNotice";
35
36
 
36
37
  // Dynamic imports for client-only components with loading states
38
+ const LogDocument = dynamic(
39
+ () =>
40
+ import("@/components/SessionLogs/LogDocument").then(
41
+ (mod) => mod.LogDocument
42
+ ),
43
+ { ssr: false }
44
+ );
45
+
37
46
  const Terminal = dynamic(
38
47
  () => import("@/components/Terminal").then((mod) => mod.Terminal),
39
48
  { ssr: false, loading: () => <TerminalSkeleton /> }
@@ -87,7 +96,7 @@ interface PaneProps {
87
96
  onAttachSession?: (terminal: TerminalHandle, session: Session) => void;
88
97
  }
89
98
 
90
- type ViewMode = "terminal" | "files" | "git" | "workers";
99
+ type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
91
100
 
92
101
  export const Pane = memo(function Pane({
93
102
  paneId,
@@ -173,6 +182,12 @@ export const Pane = memo(function Pane({
173
182
 
174
183
  const isConductor = workerCount > 0;
175
184
 
185
+ // A merged task keeps the log AgentOS wrote of it.
186
+ const merged =
187
+ !!session?.task_prompt &&
188
+ (session.task_status === "merged" || session.pr_status === "merged");
189
+ const hasLog = !!useTaskLogQuery(session?.id, merged).data;
190
+
176
191
  // Get current project and its repositories
177
192
  const currentProject = useMemo(() => {
178
193
  if (!session?.project_id) return null;
@@ -403,6 +418,7 @@ export const Pane = memo(function Pane({
403
418
  projects={projects}
404
419
  viewMode={viewMode}
405
420
  isConductor={isConductor}
421
+ hasLog={hasLog}
406
422
  workerCount={workerCount}
407
423
  onMenuClick={onMenuClick}
408
424
  onViewModeChange={setViewMode}
@@ -423,6 +439,7 @@ export const Pane = memo(function Pane({
423
439
  viewMode={viewMode}
424
440
  isFocused={isFocused}
425
441
  isConductor={isConductor}
442
+ hasLog={hasLog}
426
443
  workerCount={workerCount}
427
444
  canSplit={canSplit}
428
445
  canClose={canClose}
@@ -485,6 +502,11 @@ export const Pane = memo(function Pane({
485
502
  </div>
486
503
  )}
487
504
 
505
+ {/* Log */}
506
+ {viewMode === "log" && session && (
507
+ <LogDocument sessionId={session.id} />
508
+ )}
509
+
488
510
  {/* Workers */}
489
511
  {viewMode === "workers" && session && (
490
512
  <ConductorPanel
@@ -542,6 +564,11 @@ export const Pane = memo(function Pane({
542
564
  </div>
543
565
  )}
544
566
 
567
+ {/* Log */}
568
+ {viewMode === "log" && session && (
569
+ <LogDocument sessionId={session.id} />
570
+ )}
571
+
545
572
  {/* Workers */}
546
573
  {viewMode === "workers" && session && (
547
574
  <ConductorPanel
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { Loader2 } from "lucide-react";
5
+ import { statusKeys } from "@/data/sessions/keys";
6
+ import {
7
+ PR_STAGE_BUSY,
8
+ prStageText,
9
+ PR_STAGE_SHORT,
10
+ type PrStage,
11
+ type PrStageName,
12
+ } from "@/lib/tasks/pr-stage";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ const TONE: Record<PrStageName, string> = {
16
+ merged: "bg-purple-500/15 text-purple-700 dark:text-purple-400",
17
+ conflicts: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
18
+ "ci-failed": "bg-red-500/15 text-red-700 dark:text-red-400",
19
+ "ci-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
20
+ "ci-settling": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
21
+ "review-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
22
+ "review-blocked": "bg-red-500/15 text-red-700 dark:text-red-400",
23
+ waiting: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
24
+ armed: "bg-green-500/15 text-green-700 dark:text-green-400",
25
+ open: "bg-muted text-muted-foreground",
26
+ };
27
+
28
+ // Where a task's PR is in the gates, linking to the PR. The short stage word
29
+ // is all the text; the PR number, full wording and sha live in the tooltip
30
+ // and label. `showNumber` (the session header) prefixes "PR #n".
31
+ export function PrStageBadge({
32
+ stage,
33
+ showNumber = false,
34
+ }: {
35
+ stage?: PrStage;
36
+ showNumber?: boolean;
37
+ }) {
38
+ if (!stage) return null;
39
+ const text = prStageText(stage);
40
+ const short = PR_STAGE_SHORT[stage.stage];
41
+ const visible = !showNumber
42
+ ? short
43
+ : stage.stage === "open"
44
+ ? `PR #${stage.number}`
45
+ : `PR #${stage.number} · ${short}`;
46
+ return (
47
+ <a
48
+ href={stage.url}
49
+ target="_blank"
50
+ rel="noreferrer"
51
+ title={`${text}${stage.sha ? ` · ${stage.sha}` : ""}`}
52
+ aria-label={`${text}${stage.sha ? ` · ${stage.sha}` : ""}`}
53
+ onClick={(e) => e.stopPropagation()}
54
+ className={cn(
55
+ "inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold whitespace-nowrap hover:underline",
56
+ TONE[stage.stage]
57
+ )}
58
+ >
59
+ {PR_STAGE_BUSY.has(stage.stage) && (
60
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
61
+ )}
62
+ {visible}
63
+ </a>
64
+ );
65
+ }
66
+
67
+ // The badge for one session, read from the status the sidebar already holds.
68
+ export function SessionPrStage({ sessionId }: { sessionId?: string | null }) {
69
+ const { data } = useQuery<{
70
+ statuses: Record<string, { prStage?: PrStage }>;
71
+ }>({ queryKey: statusKeys.all, enabled: false });
72
+ return (
73
+ <PrStageBadge
74
+ stage={sessionId ? data?.statuses[sessionId]?.prStage : undefined}
75
+ showNumber
76
+ />
77
+ );
78
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import dynamic from "next/dynamic";
3
4
  import { useState, useEffect } from "react";
4
5
  import {
5
6
  Dialog,
@@ -73,6 +74,14 @@ interface ProjectSettingsDialogProps {
73
74
  onSave: () => void;
74
75
  }
75
76
 
77
+ const ProjectLogsSection = dynamic(
78
+ () =>
79
+ import("@/components/SessionLogs/ProjectLogsSection").then(
80
+ (mod) => mod.ProjectLogsSection
81
+ ),
82
+ { ssr: false }
83
+ );
84
+
76
85
  export function ProjectSettingsDialog({
77
86
  project,
78
87
  open,
@@ -766,6 +775,8 @@ export function ProjectSettingsDialog({
766
775
 
767
776
  <ProjectMergeSection projectId={project.id} open={open} />
768
777
 
778
+ <ProjectLogsSection projectId={project.id} open={open} />
779
+
769
780
  {error && <p className="text-sm text-red-500">{error}</p>}
770
781
 
771
782
  <DialogFooter>
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { Loader2 } from "lucide-react";
4
+ import { liveParts, liveText, type LiveTone } from "@/lib/sidebar/live-status";
5
+ import type { PrStage } from "@/lib/tasks/pr-stage";
6
+ import { prStageText } from "@/lib/tasks/pr-stage";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ export interface LiveInput {
10
+ compacting?: boolean;
11
+ prStage?: PrStage;
12
+ }
13
+
14
+ const TONE: Record<LiveTone, string> = {
15
+ blue: "text-blue-700 dark:text-blue-400",
16
+ amber: "text-amber-700 dark:text-amber-400",
17
+ red: "text-red-700 dark:text-red-400",
18
+ green: "text-green-700 dark:text-green-400",
19
+ purple: "text-purple-700 dark:text-purple-400",
20
+ muted: "text-muted-foreground",
21
+ };
22
+
23
+ // The row's live status in small coloured words under the name: "Compacting
24
+ // · PR CI". The PR word links to the PR; its number, full wording and sha are
25
+ // in the tooltip. Renders nothing when there is nothing to say.
26
+ export function LiveLine({ compacting, prStage }: LiveInput) {
27
+ const parts = liveParts({ compacting, prStage });
28
+ if (parts.length === 0) return null;
29
+ const pr = prStage
30
+ ? `${prStageText(prStage)}${prStage.sha ? ` · ${prStage.sha}` : ""}`
31
+ : "";
32
+ return (
33
+ <span
34
+ aria-label={liveText(parts)}
35
+ className="flex shrink-0 items-center gap-1 text-xs font-medium whitespace-nowrap"
36
+ >
37
+ {parts.map((p, i) => {
38
+ const isPr = p.text.startsWith("PR");
39
+ const body = (
40
+ <>
41
+ {p.busy && (
42
+ <Loader2 className="h-2.5 w-2.5 animate-spin" aria-hidden />
43
+ )}
44
+ {p.text}
45
+ </>
46
+ );
47
+ return (
48
+ <span key={p.text} className="flex items-center gap-1">
49
+ {i > 0 && <span className="text-muted-foreground/50">·</span>}
50
+ {isPr && prStage ? (
51
+ <a
52
+ href={prStage.url}
53
+ target="_blank"
54
+ rel="noreferrer"
55
+ title={pr}
56
+ aria-label={pr}
57
+ onClick={(e) => e.stopPropagation()}
58
+ className={cn(
59
+ "flex items-center gap-1 hover:underline",
60
+ TONE[p.tone]
61
+ )}
62
+ >
63
+ {body}
64
+ </a>
65
+ ) : (
66
+ <span className={cn("flex items-center gap-1", TONE[p.tone])}>
67
+ {body}
68
+ </span>
69
+ )}
70
+ </span>
71
+ );
72
+ })}
73
+ </span>
74
+ );
75
+ }
@@ -26,7 +26,7 @@ export function RowDot({ row }: { row: SidebarRow }) {
26
26
  );
27
27
  }
28
28
 
29
- // What it needs from you, or a purple dot when it changed since you looked.
29
+ // What it needs from you, stalled, or a purple dot when it changed since you looked.
30
30
  export function RowBadge({ row }: { row: SidebarRow }) {
31
31
  if (row.need) {
32
32
  const asks = row.status?.asks ?? 0;
@@ -44,12 +44,6 @@ export function RowBadge({ row }: { row: SidebarRow }) {
44
44
  </span>
45
45
  );
46
46
  }
47
- if (row.compacting)
48
- return (
49
- <span className="rounded-md bg-blue-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-blue-700 dark:text-blue-400">
50
- Compacting
51
- </span>
52
- );
53
47
  if (row.status?.stalled)
54
48
  return (
55
49
  <span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
@@ -65,7 +65,7 @@ export function RowShell({
65
65
  )}
66
66
  >
67
67
  {leading}
68
- <span className="flex min-w-0 flex-1 flex-col leading-tight">
68
+ <span className="flex min-w-[7rem] flex-1 flex-col leading-tight">
69
69
  {children}
70
70
  </span>
71
71
  {aside}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { HostBadge } from "@/components/Hosts";
4
4
  import type { Session } from "@/lib/db";
5
+ import { LiveLine, type LiveInput } from "./LiveLine";
5
6
  import { headerParts } from "@/lib/orchestrator/header-line";
6
7
  import { cn } from "@/lib/utils";
7
8
  import { usageLabel, useSessionUsage } from "@/data/load";
@@ -16,9 +17,11 @@ const ATTENTION = (part: string) => part === "paused" || /\basks?$/.test(part);
16
17
  export function RowSubtitle({
17
18
  session,
18
19
  detail,
20
+ live,
19
21
  }: {
20
22
  session: Session;
21
23
  detail?: string | null;
24
+ live?: LiveInput;
22
25
  }) {
23
26
  const ctx = useRowContext();
24
27
  // What its processes use, on a busy session: "3.1 cores · 4.2 GB".
@@ -52,6 +55,7 @@ export function RowSubtitle({
52
55
  const project = ctx.projectNames.get(session.project_id ?? "");
53
56
  return (
54
57
  <span className="flex min-w-0 items-center gap-1.5">
58
+ <LiveLine {...live} />
55
59
  {(project || detail || usage) && (
56
60
  <span className="text-muted-foreground/70 truncate text-xs">
57
61
  {[usage && usageLabel(usage), detail?.slice(0, 200), project]
@@ -1,3 +1,4 @@
1
+ import type { PrStage } from "@/lib/tasks/pr-stage";
1
2
  import type { SessionNeed } from "@/lib/sidebar/shelves";
2
3
 
3
4
  export interface SessionStatus {
@@ -11,6 +12,7 @@ export interface SessionStatus {
11
12
  unread?: boolean;
12
13
  stalled?: boolean;
13
14
  compacting?: { since: number; before?: number };
15
+ prStage?: PrStage;
14
16
  }
15
17
 
16
18
  export interface SessionListProps {
@@ -122,7 +122,15 @@ export const SessionRow = memo(function SessionRow({
122
122
  {session.name}
123
123
  </span>
124
124
  )}
125
- <RowSubtitle session={session} detail={row.status?.detail} />
125
+ <RowSubtitle
126
+ session={session}
127
+ detail={row.status?.detail}
128
+ live={
129
+ row.need
130
+ ? undefined
131
+ : { compacting: row.compacting, prStage: row.status?.prStage }
132
+ }
133
+ />
126
134
  </RowShell>
127
135
  {session.role === "orchestrator" && row.need && (
128
136
  <OrchestratorAsks workspaceId={session.workspace_id} />
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { Markdown } from "@/components/Chat/Markdown";
4
+ import { useTaskLogQuery } from "@/data/session-logs";
5
+
6
+ // A merged task's stored log, as the document AgentOS wrote.
7
+ export function LogDocument({ sessionId }: { sessionId: string }) {
8
+ const { data, isPending, isError, error } = useTaskLogQuery(sessionId);
9
+ if (isPending)
10
+ return <div className="bg-muted/40 m-4 h-40 animate-pulse rounded-lg" />;
11
+ if (isError)
12
+ return <p className="text-destructive p-4 text-sm">{error.message}</p>;
13
+ if (!data)
14
+ return (
15
+ <p className="text-muted-foreground p-4 text-sm">This task has no log.</p>
16
+ );
17
+ return (
18
+ <div className="h-full overflow-y-auto">
19
+ <div className="mx-auto max-w-3xl px-4 py-4">
20
+ <Markdown text={data.markdown} />
21
+ {data.header.filePath && (
22
+ <p className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
23
+ Also saved at {data.header.filePath}
24
+ </p>
25
+ )}
26
+ </div>
27
+ </div>
28
+ );
29
+ }