@saadnvd1/agent-os 0.3.459 → 0.3.461
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.
- package/README.md +53 -1
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Workspaces/WorkspaceSettings.tsx +9 -1
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +101 -0
- package/lib/db/types.ts +9 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/brief.ts +2 -0
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/watcher.ts +12 -2
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -494,13 +494,29 @@ root. Every field is optional:
|
|
|
494
494
|
"browse": { "login": "/dev/login", "map": "docs/browsing.md" },
|
|
495
495
|
"database": { "from": "app_development", "env": "DATABASE_NAME" },
|
|
496
496
|
"notes": ["Kill your own server by port: lsof -ti tcp:$RAILS_PORT"],
|
|
497
|
-
"merge": { "method": "squash" }
|
|
497
|
+
"merge": { "method": "squash" },
|
|
498
|
+
"deploy": {
|
|
499
|
+
"deployed": "~/.deploy/deployed",
|
|
500
|
+
"health": "https://app.example.com/up"
|
|
501
|
+
}
|
|
498
502
|
}
|
|
499
503
|
```
|
|
500
504
|
|
|
501
505
|
`merge` sets the project's merge method and branch clean-up (see
|
|
502
506
|
[Merging settings](#tasks)).
|
|
503
507
|
|
|
508
|
+
`deploy` is how [Away mode](#orchestrator) checks a merge went live: `deployed`
|
|
509
|
+
is a file your deploy writes the deployed commit to, `failed` (optional) one it
|
|
510
|
+
writes a commit whose deploy failed to, and `health` (optional) a URL that
|
|
511
|
+
answers 2xx once the server is back (a path alone is this AgentOS server's own,
|
|
512
|
+
as this repository's `agentos.json` uses with `scripts/autodeploy`'s state
|
|
513
|
+
files). A merge counts as deployed once the `deployed` commit contains it and
|
|
514
|
+
`health` answers; as failed when the `failed` commit contains it, the server
|
|
515
|
+
doesn't come back within 3 minutes (or, when its health answer has a
|
|
516
|
+
`startedAt`, hasn't restarted since the merge), or it isn't deployed `within_minutes`
|
|
517
|
+
(30) after the merge. No `deployed` file on the machine means nothing deploys
|
|
518
|
+
there, and nothing is checked.
|
|
519
|
+
|
|
504
520
|
Every task and worktree session takes a **port slot**, and each named port is
|
|
505
521
|
`base + slot` (a project with no `ports` still gets a `PORT` from 3100), so
|
|
506
522
|
parallel sessions never share a dev server port. The slot is kept on the
|
|
@@ -900,6 +916,18 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
900
916
|
unchanged (same `git patch-id`; a fresh review starts otherwise). Not
|
|
901
917
|
clean: the task is asked once to rebase. Either way, one event. Main and
|
|
902
918
|
other tasks' branches are never written.
|
|
919
|
+
- **Tasks woken on CI failures and reviews:** a check failing on a task's
|
|
920
|
+
PR head messages the task's own agent once per head, with the check, the
|
|
921
|
+
failing step and about 60 lines of `gh run view --log-failed` around the
|
|
922
|
+
error, telling it to fix the root cause, push and update its Reviewed:
|
|
923
|
+
line. A review or comment from a collaborator (not the account gh runs
|
|
924
|
+
as, which is the task's own) wakes it once per review or comment; one
|
|
925
|
+
GraphQL query per repository a minute fingerprints the open task PRs, and
|
|
926
|
+
only a PR whose fingerprint moved has its reviews read. Comment-only wakes
|
|
927
|
+
stop at 10 a task; changes requested always wake. A task mid-turn gets it
|
|
928
|
+
queued, the orchestrator hears one line per wake, and merged, closed,
|
|
929
|
+
dropped or done tasks aren't watched. On by default; **Settings →
|
|
930
|
+
Workspaces** turns it off per workspace.
|
|
903
931
|
- **External PRs and sessions:** `review` and `sign_off` also take an open
|
|
904
932
|
PR no AgentOS task owns (dispatch, a person, dependabot) by `#N`,
|
|
905
933
|
`owner/repo#N` or URL, as long as its repository is one of the workspace's
|
|
@@ -995,6 +1023,30 @@ reset` yourself in a terminal on the machine: it refuses inside an
|
|
|
995
1023
|
the same line carries Pause/Resume. While paused it acts on nothing:
|
|
996
1024
|
events queue, acting tools refuse, and its stack starts and lands hold.
|
|
997
1025
|
It can still read, note and ask. Resume delivers what queued, folded.
|
|
1026
|
+
- **Away mode:** turn it on when you step away, from the coffee cup in the
|
|
1027
|
+
sidebar header or ⌘K ("Away for 4 hours"), for 1 hour, 4 hours, until
|
|
1028
|
+
morning (8 AM) or until you're back. It's kept in `away_periods` (who set
|
|
1029
|
+
it, when, when it ends), the cup turns amber while it's on (with an amber dot on the phone's menu button), and it
|
|
1030
|
+
ends on its own at its time. Every orchestrator gets an event, and a line
|
|
1031
|
+
leading its digest, saying you're away and to follow work through rather
|
|
1032
|
+
than only react. Every 10 minutes a sweep, written as code with no model
|
|
1033
|
+
scanning, acts on what it finds and logs each step in the orchestrator's
|
|
1034
|
+
chat: a task quiet 30 minutes with no PR (or failing CI) is nudged, at most
|
|
1035
|
+
twice; an idle task with uncommitted work and no PR is asked to commit and
|
|
1036
|
+
open one or say why not; a PR green and settled whose review never started
|
|
1037
|
+
or was lost has it run again (once seen on two sweeps, so the orchestrator
|
|
1038
|
+
had its turn); and after each merge, the project's `deploy` check says
|
|
1039
|
+
whether it went live and the server came back, with a failure sent to the
|
|
1040
|
+
orchestrator as an event. Interactive sessions are yours: one with
|
|
1041
|
+
uncommitted work is asked once to wrap up, and nothing more. Paused
|
|
1042
|
+
workspaces aren't swept. Hard lines still become asks, and while you're
|
|
1043
|
+
away the asks raised, failed deploys and any `aos notify` an orchestrator
|
|
1044
|
+
sends reach your phone as one message per sweep. Turning it off (or its running out) builds "While you were
|
|
1045
|
+
away" from the database: what merged and deployed with PR links, what's
|
|
1046
|
+
still in flight and which gate or blocker holds it, what failed, was dropped
|
|
1047
|
+
or was escalated, and what waits on you (an explicit "none"). It opens on
|
|
1048
|
+
whichever device is open until you dismiss it, a short summary goes to
|
|
1049
|
+
your phone, and the report stays in Settings → Audits (`away/`).
|
|
998
1050
|
|
|
999
1051
|
## LumifyHub (optional)
|
|
1000
1052
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { getDevice } from "@/lib/security/devices";
|
|
4
|
+
import { turnAwayOff, turnAwayOn } from "@/lib/away/run";
|
|
5
|
+
import { awayView } from "@/lib/away/view";
|
|
6
|
+
|
|
7
|
+
// GET /api/away - whether Saad is away, and the report he hasn't dismissed.
|
|
8
|
+
export function GET() {
|
|
9
|
+
return NextResponse.json(awayView());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// POST /api/away - { on: true, expiresAt: ISO | null } turns Away on (or
|
|
13
|
+
// moves its end); { on: false } turns it off and builds the report. From a
|
|
14
|
+
// trusted place, as Pause is: it changes what the orchestrators do.
|
|
15
|
+
export async function POST(request: NextRequest) {
|
|
16
|
+
const gate = requireApprover(request);
|
|
17
|
+
if (!gate.ok) return gate.response;
|
|
18
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
19
|
+
on?: unknown;
|
|
20
|
+
expiresAt?: unknown;
|
|
21
|
+
};
|
|
22
|
+
if (typeof body.on !== "boolean")
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "on must be true or false" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
if (!body.on) {
|
|
28
|
+
turnAwayOff("saad");
|
|
29
|
+
return NextResponse.json(awayView());
|
|
30
|
+
}
|
|
31
|
+
let expiresAt: number | null = null;
|
|
32
|
+
if (body.expiresAt !== null && body.expiresAt !== undefined) {
|
|
33
|
+
expiresAt =
|
|
34
|
+
typeof body.expiresAt === "string" ? Date.parse(body.expiresAt) : NaN;
|
|
35
|
+
if (!Number.isFinite(expiresAt))
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ error: "expiresAt must be a time or null" },
|
|
38
|
+
{ status: 400 }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const { approver } = gate;
|
|
42
|
+
const setBy = approver.deviceId
|
|
43
|
+
? (getDevice(approver.deviceId)?.name ?? "a device")
|
|
44
|
+
: approver.via === "loopback"
|
|
45
|
+
? "this machine"
|
|
46
|
+
: "the tailnet";
|
|
47
|
+
try {
|
|
48
|
+
turnAwayOn({ setBy, expiresAt });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
52
|
+
{ status: 400 }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return NextResponse.json(awayView());
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { markReportSeen } from "@/lib/away/state";
|
|
3
|
+
|
|
4
|
+
// POST /api/away/seen - { id }: dismiss that "While you were away" report.
|
|
5
|
+
// It stays in Settings > Audits.
|
|
6
|
+
export async function POST(request: NextRequest) {
|
|
7
|
+
const { id } = (await request.json().catch(() => ({}))) as { id?: unknown };
|
|
8
|
+
if (typeof id !== "number" || !Number.isInteger(id))
|
|
9
|
+
return NextResponse.json({ error: "id must be a number" }, { status: 400 });
|
|
10
|
+
markReportSeen(id);
|
|
11
|
+
return NextResponse.json({ ok: true });
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
4
|
+
|
|
5
|
+
// GET /api/health - the server is up and answering; when it started, so a
|
|
6
|
+
// deploy check can tell it restarted after a merge (lib/away/deploy.ts).
|
|
7
|
+
export function GET() {
|
|
8
|
+
return NextResponse.json({ ok: true, startedAt });
|
|
9
|
+
}
|
|
@@ -5,7 +5,13 @@ type RouteParams = { params: Promise<{ id: string }> };
|
|
|
5
5
|
|
|
6
6
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
7
7
|
const { id } = await params;
|
|
8
|
-
const { name, collapsed, sortOrder, maxRunningTasks } =
|
|
8
|
+
const { name, collapsed, sortOrder, maxRunningTasks, wakeTasksOnPR } =
|
|
9
|
+
await request.json();
|
|
10
|
+
if (wakeTasksOnPR !== undefined && typeof wakeTasksOnPR !== "boolean")
|
|
11
|
+
return NextResponse.json(
|
|
12
|
+
{ error: "wakeTasksOnPR is true or false" },
|
|
13
|
+
{ status: 400 }
|
|
14
|
+
);
|
|
9
15
|
let workspace;
|
|
10
16
|
try {
|
|
11
17
|
workspace = updateWorkspace(id, {
|
|
@@ -13,6 +19,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
13
19
|
collapsed,
|
|
14
20
|
sortOrder,
|
|
15
21
|
maxRunningTasks,
|
|
22
|
+
wakeTasksOnPR,
|
|
16
23
|
});
|
|
17
24
|
} catch (error) {
|
|
18
25
|
const message = error instanceof Error ? error.message : String(error);
|
package/app/page.tsx
CHANGED
|
@@ -32,6 +32,7 @@ if (typeof window !== "undefined") {
|
|
|
32
32
|
}
|
|
33
33
|
import { PaneProvider, usePanes } from "@/contexts/PaneContext";
|
|
34
34
|
import { Pane } from "@/components/Pane";
|
|
35
|
+
import { AwayReport } from "@/components/Away/AwayReport";
|
|
35
36
|
import { useNotifications } from "@/hooks/useNotifications";
|
|
36
37
|
import { useViewport } from "@/hooks/useViewport";
|
|
37
38
|
import { useViewportHeight } from "@/hooks/useViewportHeight";
|
|
@@ -128,6 +129,7 @@ const AppDialogs = memo(function AppDialogs() {
|
|
|
128
129
|
<LumifyHubDialogs />
|
|
129
130
|
<UsageDialog />
|
|
130
131
|
<MoveDialog />
|
|
132
|
+
<AwayReport />
|
|
131
133
|
<CommandPalette />
|
|
132
134
|
</>
|
|
133
135
|
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
import { useAwayQuery } from "@/data/away";
|
|
5
|
+
|
|
6
|
+
const AwayReportDialog = dynamic(
|
|
7
|
+
() => import("./AwayReportDialog").then((m) => m.AwayReportDialog),
|
|
8
|
+
{ ssr: false }
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
// Opens "While you were away" when Away has ended and its report hasn't
|
|
12
|
+
// been dismissed, on whichever device is open.
|
|
13
|
+
export function AwayReport() {
|
|
14
|
+
const { data } = useAwayQuery();
|
|
15
|
+
return data?.report ? <AwayReportDialog report={data.report} /> : null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ExternalLink } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import type {
|
|
15
|
+
AwayReport,
|
|
16
|
+
DeployState,
|
|
17
|
+
WorkspaceReport,
|
|
18
|
+
} from "@/lib/away/report";
|
|
19
|
+
import { useDismissAwayReport } from "@/data/away";
|
|
20
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
21
|
+
import { cn } from "@/lib/utils";
|
|
22
|
+
import { awayRange } from "./time";
|
|
23
|
+
|
|
24
|
+
const DEPLOY: Record<DeployState, { label: string; tone: string }> = {
|
|
25
|
+
deployed: {
|
|
26
|
+
label: "Deployed",
|
|
27
|
+
tone: "text-emerald-700 dark:text-emerald-400",
|
|
28
|
+
},
|
|
29
|
+
failed: { label: "Deploy failed", tone: "text-destructive" },
|
|
30
|
+
pending: {
|
|
31
|
+
label: "Deploy not confirmed",
|
|
32
|
+
tone: "text-amber-700 dark:text-amber-300",
|
|
33
|
+
},
|
|
34
|
+
unchecked: { label: "No deploy check", tone: "text-muted-foreground" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function PrLink({ pr, url }: { pr: number | null; url: string | null }) {
|
|
38
|
+
if (!pr) return null;
|
|
39
|
+
if (!url || !url.startsWith("https://"))
|
|
40
|
+
return <span className="text-muted-foreground">#{pr}</span>;
|
|
41
|
+
return (
|
|
42
|
+
<a
|
|
43
|
+
href={url}
|
|
44
|
+
target="_blank"
|
|
45
|
+
rel="noreferrer"
|
|
46
|
+
className="text-primary inline-flex min-h-11 items-center gap-1 underline-offset-2 hover:underline md:min-h-0"
|
|
47
|
+
>
|
|
48
|
+
#{pr}
|
|
49
|
+
<ExternalLink className="h-3 w-3" />
|
|
50
|
+
</a>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Row({ children }: { children: ReactNode }) {
|
|
55
|
+
return (
|
|
56
|
+
<li className="bg-foreground/[0.03] rounded-xl px-3 py-2 text-sm break-words">
|
|
57
|
+
{children}
|
|
58
|
+
</li>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Section<T>({
|
|
63
|
+
title,
|
|
64
|
+
report,
|
|
65
|
+
pick,
|
|
66
|
+
render,
|
|
67
|
+
empty,
|
|
68
|
+
}: {
|
|
69
|
+
title: string;
|
|
70
|
+
report: AwayReport;
|
|
71
|
+
pick: (w: WorkspaceReport) => T[];
|
|
72
|
+
render: (item: T) => ReactNode;
|
|
73
|
+
empty: string;
|
|
74
|
+
}) {
|
|
75
|
+
const groups = report.workspaces.filter((w) => pick(w).length);
|
|
76
|
+
const many = report.workspaces.length > 1;
|
|
77
|
+
return (
|
|
78
|
+
<section className="space-y-1.5">
|
|
79
|
+
<h3 className="label-mono text-muted-foreground">{title}</h3>
|
|
80
|
+
{groups.length ? (
|
|
81
|
+
<ul className="space-y-1.5">
|
|
82
|
+
{groups.flatMap((w) =>
|
|
83
|
+
pick(w).map((item, i) => (
|
|
84
|
+
<Row key={`${w.id}-${i}`}>
|
|
85
|
+
{many && (
|
|
86
|
+
<span className="text-muted-foreground mr-1.5 text-xs">
|
|
87
|
+
{w.name}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
{render(item)}
|
|
91
|
+
</Row>
|
|
92
|
+
))
|
|
93
|
+
)}
|
|
94
|
+
</ul>
|
|
95
|
+
) : (
|
|
96
|
+
<p className="text-muted-foreground px-1 text-sm">{empty}</p>
|
|
97
|
+
)}
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function AwayReportDialog({ report }: { report: AwayReport }) {
|
|
103
|
+
const dismiss = useDismissAwayReport();
|
|
104
|
+
const close = () => dismiss.mutate(report.awayId);
|
|
105
|
+
const t = report.totals;
|
|
106
|
+
return (
|
|
107
|
+
<Dialog open onOpenChange={(open) => !open && close()}>
|
|
108
|
+
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto [&>*]:min-w-0">
|
|
109
|
+
<DialogHeader>
|
|
110
|
+
<DialogTitle>While you were away</DialogTitle>
|
|
111
|
+
<DialogDescription>
|
|
112
|
+
{awayRange(report.startedAt, report.endedAt)}
|
|
113
|
+
{report.endedBy === "expired" ? ", when Away ran out" : ""}.{" "}
|
|
114
|
+
{t.merged} merged, {t.inFlight} in flight, {t.failed} failed or
|
|
115
|
+
escalated, {t.waiting} waiting on you.
|
|
116
|
+
</DialogDescription>
|
|
117
|
+
</DialogHeader>
|
|
118
|
+
<div className="space-y-4">
|
|
119
|
+
<Section
|
|
120
|
+
title="Waiting on you"
|
|
121
|
+
report={report}
|
|
122
|
+
pick={(w) => w.waiting}
|
|
123
|
+
empty="None. Nothing is waiting on you."
|
|
124
|
+
render={(a) => (
|
|
125
|
+
<>
|
|
126
|
+
{a.hardLine && (
|
|
127
|
+
<span className="text-destructive mr-1.5 text-xs font-medium">
|
|
128
|
+
Hard line
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
<span className="text-muted-foreground mr-1">{a.kind}:</span>
|
|
132
|
+
{a.title}
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
/>
|
|
136
|
+
<Section
|
|
137
|
+
title="Merged"
|
|
138
|
+
report={report}
|
|
139
|
+
pick={(w) => w.merged}
|
|
140
|
+
empty="None."
|
|
141
|
+
render={(m) => (
|
|
142
|
+
<>
|
|
143
|
+
<span className="font-medium">{m.task}</span>{" "}
|
|
144
|
+
<PrLink pr={m.pr} url={m.url} />
|
|
145
|
+
<span className={cn("block text-xs", DEPLOY[m.deploy].tone)}>
|
|
146
|
+
{DEPLOY[m.deploy].label}
|
|
147
|
+
{m.deployDetail ? `: ${m.deployDetail}` : ""}
|
|
148
|
+
</span>
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
/>
|
|
152
|
+
<Section
|
|
153
|
+
title="Still in flight"
|
|
154
|
+
report={report}
|
|
155
|
+
pick={(w) => w.inFlight}
|
|
156
|
+
empty="None."
|
|
157
|
+
render={(f) => (
|
|
158
|
+
<>
|
|
159
|
+
<span className="font-medium">{f.task}</span>{" "}
|
|
160
|
+
<PrLink pr={f.pr} url={f.url} />
|
|
161
|
+
<span className="text-muted-foreground block text-xs">
|
|
162
|
+
{f.why}
|
|
163
|
+
</span>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
/>
|
|
167
|
+
<Section
|
|
168
|
+
title="Failed or escalated"
|
|
169
|
+
report={report}
|
|
170
|
+
pick={(w) => w.failed}
|
|
171
|
+
empty="None."
|
|
172
|
+
render={(f) => f.what}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
<DialogFooter className="gap-2">
|
|
176
|
+
<Button
|
|
177
|
+
variant="secondary"
|
|
178
|
+
className="h-11 md:h-9"
|
|
179
|
+
onClick={() => {
|
|
180
|
+
close();
|
|
181
|
+
settingsUiActions.open("audits");
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
Kept in Audits
|
|
185
|
+
</Button>
|
|
186
|
+
<Button className="h-11 md:h-9" onClick={close}>
|
|
187
|
+
Done
|
|
188
|
+
</Button>
|
|
189
|
+
</DialogFooter>
|
|
190
|
+
</DialogContent>
|
|
191
|
+
</Dialog>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Coffee, Undo2 } from "lucide-react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import {
|
|
6
|
+
ADropdownMenu,
|
|
7
|
+
menuItem,
|
|
8
|
+
separator,
|
|
9
|
+
} from "@/components/a/ADropdownMenu";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import {
|
|
12
|
+
AWAY_DURATIONS,
|
|
13
|
+
awayEnds,
|
|
14
|
+
useAwayQuery,
|
|
15
|
+
useSetAway,
|
|
16
|
+
type AwayDuration,
|
|
17
|
+
} from "@/data/away";
|
|
18
|
+
import { cn } from "@/lib/utils";
|
|
19
|
+
import { awayUntil } from "./time";
|
|
20
|
+
|
|
21
|
+
// The sidebar header's Away switch: off, a coffee cup that offers how
|
|
22
|
+
// long; on, the same cup in amber, whose menu says until when and turns
|
|
23
|
+
// it off.
|
|
24
|
+
export function AwayToggle() {
|
|
25
|
+
const { data } = useAwayQuery();
|
|
26
|
+
const setAway = useSetAway();
|
|
27
|
+
const away = data?.away ?? null;
|
|
28
|
+
const choose = (d: AwayDuration) =>
|
|
29
|
+
setAway.mutate(
|
|
30
|
+
{ expiresAt: awayEnds(d) },
|
|
31
|
+
{ onError: (e) => toast.error(e.message) }
|
|
32
|
+
);
|
|
33
|
+
const durations = AWAY_DURATIONS.map((d) =>
|
|
34
|
+
menuItem(away ? d.label.replace(/^For/, "Change to") : d.label, () =>
|
|
35
|
+
choose(d.id)
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
const until = away ? awayUntil(away.expiresAt) : "";
|
|
39
|
+
return (
|
|
40
|
+
<ADropdownMenu
|
|
41
|
+
minWidth="220px"
|
|
42
|
+
trigger={
|
|
43
|
+
<Button
|
|
44
|
+
variant="ghost"
|
|
45
|
+
size="icon-sm"
|
|
46
|
+
aria-label={away ? `Away ${until}` : "Away"}
|
|
47
|
+
title={
|
|
48
|
+
away
|
|
49
|
+
? `Away ${until}`
|
|
50
|
+
: "Away: let the orchestrators follow work through"
|
|
51
|
+
}
|
|
52
|
+
disabled={setAway.isPending}
|
|
53
|
+
className={cn(
|
|
54
|
+
"relative h-11 w-11 md:h-8 md:w-8",
|
|
55
|
+
away &&
|
|
56
|
+
"bg-amber-500/20 text-amber-700 ring-1 ring-amber-500/50 hover:bg-amber-500/30 dark:text-amber-300"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Coffee className="h-4 w-4" />
|
|
60
|
+
</Button>
|
|
61
|
+
}
|
|
62
|
+
items={
|
|
63
|
+
away
|
|
64
|
+
? [
|
|
65
|
+
menuItem(
|
|
66
|
+
"I'm back",
|
|
67
|
+
() =>
|
|
68
|
+
setAway.mutate(false, {
|
|
69
|
+
onError: (e) => toast.error(e.message),
|
|
70
|
+
}),
|
|
71
|
+
{
|
|
72
|
+
icon: Undo2,
|
|
73
|
+
description: `Away ${until}. Turning it off shows what happened.`,
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
separator(),
|
|
77
|
+
...durations,
|
|
78
|
+
]
|
|
79
|
+
: [
|
|
80
|
+
menuItem("Away", () => {}, {
|
|
81
|
+
disabled: true,
|
|
82
|
+
description:
|
|
83
|
+
"Orchestrators follow work through; asks reach your phone in batches.",
|
|
84
|
+
}),
|
|
85
|
+
separator(),
|
|
86
|
+
...durations,
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { awayRange } from "./time";
|
|
3
|
+
|
|
4
|
+
// A zone that isn't UTC, so reading UTC as local would show.
|
|
5
|
+
const zone = process.env.TZ;
|
|
6
|
+
process.env.TZ = "America/Chicago";
|
|
7
|
+
afterAll(() => {
|
|
8
|
+
process.env.TZ = zone;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("the report's time range", () => {
|
|
12
|
+
it("reads SQLite's UTC times as UTC, not local", () => {
|
|
13
|
+
expect(
|
|
14
|
+
awayRange("2026-10-11 03:01:00", "2026-10-11 05:30:00", "en-US")
|
|
15
|
+
).toMatch(/^Sat 10:01\sPM to Sun 12:30\sAM$/);
|
|
16
|
+
expect(
|
|
17
|
+
awayRange("2026-10-11 15:00:00", "2026-10-11 17:30:00", "en-US")
|
|
18
|
+
).toMatch(/^10:00\sAM to 12:30\sPM$/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// `locale` undefined: the browser's own.
|
|
2
|
+
const clock = (d: Date, locale?: string) =>
|
|
3
|
+
d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
|
4
|
+
|
|
5
|
+
// "until 4:30 PM", "until Sat 8:00 AM", or "until you're back".
|
|
6
|
+
export function awayUntil(expiresAt: string | null, now = new Date()): string {
|
|
7
|
+
if (!expiresAt) return "until you're back";
|
|
8
|
+
const d = new Date(expiresAt);
|
|
9
|
+
const day =
|
|
10
|
+
d.toDateString() === now.toDateString()
|
|
11
|
+
? ""
|
|
12
|
+
: `${d.toLocaleDateString(undefined, { weekday: "short" })} `;
|
|
13
|
+
return `until ${day}${clock(d)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The report's times are SQLite's UTC "YYYY-MM-DD HH:MM:SS".
|
|
17
|
+
const utc = (t: string) =>
|
|
18
|
+
new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t.replace(" ", "T")}Z`);
|
|
19
|
+
|
|
20
|
+
export function awayRange(
|
|
21
|
+
startedAt: string,
|
|
22
|
+
endedAt: string,
|
|
23
|
+
locale?: string
|
|
24
|
+
): string {
|
|
25
|
+
const a = utc(startedAt);
|
|
26
|
+
const b = utc(endedAt);
|
|
27
|
+
const sameDay = a.toDateString() === b.toDateString();
|
|
28
|
+
const day = (d: Date) => d.toLocaleDateString(locale, { weekday: "short" });
|
|
29
|
+
return sameDay
|
|
30
|
+
? `${clock(a, locale)} to ${clock(b, locale)}`
|
|
31
|
+
: `${day(a)} ${clock(a, locale)} to ${day(b)} ${clock(b, locale)}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Coffee, Undo2 } from "lucide-react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import type { PaletteCommand } from "@/lib/palette/registry";
|
|
6
|
+
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
7
|
+
import {
|
|
8
|
+
AWAY_DURATIONS,
|
|
9
|
+
awayEnds,
|
|
10
|
+
useAwayQuery,
|
|
11
|
+
useSetAway,
|
|
12
|
+
} from "@/data/away";
|
|
13
|
+
|
|
14
|
+
// ⌘K: "Away for 4 hours", "I'm back", and the rest of the toggle's menu.
|
|
15
|
+
export function useAwayCommands(): void {
|
|
16
|
+
const { data } = useAwayQuery();
|
|
17
|
+
const setAway = useSetAway();
|
|
18
|
+
const on = !!data?.away;
|
|
19
|
+
const onError = (e: Error) => toast.error(e.message);
|
|
20
|
+
const commands: PaletteCommand[] = [
|
|
21
|
+
...(on
|
|
22
|
+
? [
|
|
23
|
+
{
|
|
24
|
+
id: "away.off",
|
|
25
|
+
title: "I'm back (turn Away off)",
|
|
26
|
+
group: "Away",
|
|
27
|
+
keywords: ["away", "back", "return"],
|
|
28
|
+
icon: Undo2,
|
|
29
|
+
run: () => setAway.mutate(false, { onError }),
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
: []),
|
|
33
|
+
...AWAY_DURATIONS.map((d) => ({
|
|
34
|
+
id: `away.${d.id}`,
|
|
35
|
+
title: `${on ? "Stay away" : "Away"} ${d.label.replace(/^For/, "for").replace(/^Until/, "until")}`,
|
|
36
|
+
group: "Away",
|
|
37
|
+
keywords: ["away", "afk", "step away", "leave"],
|
|
38
|
+
icon: Coffee,
|
|
39
|
+
run: () => setAway.mutate({ expiresAt: awayEnds(d.id) }, { onError }),
|
|
40
|
+
})),
|
|
41
|
+
];
|
|
42
|
+
usePaletteCommands("away", commands);
|
|
43
|
+
}
|
|
@@ -32,6 +32,7 @@ import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
|
|
|
32
32
|
import { usageUiActions } from "@/stores/usageUi";
|
|
33
33
|
import { settingsCommands } from "@/components/Settings/commands";
|
|
34
34
|
import { DRAFT_KEYS } from "@/lib/drafts";
|
|
35
|
+
import { useAwayCommands } from "@/components/Away/useAwayCommands";
|
|
35
36
|
|
|
36
37
|
const LAST_THEME = "agentOS-last-theme-";
|
|
37
38
|
|
|
@@ -181,6 +182,7 @@ export function useAppCommands({
|
|
|
181
182
|
})),
|
|
182
183
|
];
|
|
183
184
|
usePaletteCommands("app", app);
|
|
185
|
+
useAwayCommands();
|
|
184
186
|
|
|
185
187
|
usePaletteCommands(
|
|
186
188
|
"sessions",
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
Users,
|
|
28
28
|
} from "lucide-react";
|
|
29
29
|
import { cn } from "@/lib/utils";
|
|
30
|
+
import { useAwayQuery } from "@/data/away";
|
|
31
|
+
import { awayUntil } from "@/components/Away/time";
|
|
30
32
|
import type { Session, Project } from "@/lib/db";
|
|
31
33
|
import type { LucideIcon } from "lucide-react";
|
|
32
34
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
@@ -98,6 +100,7 @@ export function MobileTabBar({
|
|
|
98
100
|
onSelectSession,
|
|
99
101
|
onFind,
|
|
100
102
|
}: MobileTabBarProps) {
|
|
103
|
+
const away = useAwayQuery().data?.away ?? null;
|
|
101
104
|
// Find current session index and calculate prev/next
|
|
102
105
|
const currentIndex = session
|
|
103
106
|
? sessions.findIndex((s) => s.id === session.id)
|
|
@@ -164,9 +167,19 @@ export function MobileTabBar({
|
|
|
164
167
|
e.stopPropagation();
|
|
165
168
|
onMenuClick();
|
|
166
169
|
}}
|
|
167
|
-
|
|
170
|
+
aria-label={
|
|
171
|
+
away ? `Menu (Away ${awayUntil(away.expiresAt)})` : "Menu"
|
|
172
|
+
}
|
|
173
|
+
className="relative h-8 w-8 shrink-0 after:absolute after:-inset-1.5 after:content-['']"
|
|
168
174
|
>
|
|
169
175
|
<Menu className="h-4 w-4" />
|
|
176
|
+
{/* The sidebar's amber cup is out of sight here: Away still shows. */}
|
|
177
|
+
{away && (
|
|
178
|
+
<span
|
|
179
|
+
aria-hidden
|
|
180
|
+
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-amber-500"
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
170
183
|
</Button>
|
|
171
184
|
)}
|
|
172
185
|
|