@saadnvd1/agent-os 0.3.460 → 0.3.462
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 +51 -3
- 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/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/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- 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 +69 -0
- package/lib/db/types.ts +4 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/watcher.ts +6 -0
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/worktree-hooks.ts +9 -0
- package/package.json +1 -1
- package/scripts/hooks/staged-secrets.test.ts +92 -0
- package/scripts/hooks/staged-secrets.ts +64 -0
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
|
|
@@ -1007,6 +1023,30 @@ reset` yourself in a terminal on the machine: it refuses inside an
|
|
|
1007
1023
|
the same line carries Pause/Resume. While paused it acts on nothing:
|
|
1008
1024
|
events queue, acting tools refuse, and its stack starts and lands hold.
|
|
1009
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/`).
|
|
1010
1050
|
|
|
1011
1051
|
## LumifyHub (optional)
|
|
1012
1052
|
|
|
@@ -1222,8 +1262,16 @@ descriptors, child processes and spawns per minute; `scripts/perf/execs.mjs
|
|
|
1222
1262
|
spawns with eslogger (macOS) or strace (Linux) and need passwordless sudo for
|
|
1223
1263
|
that one number. Run them on a machine with room to spare.
|
|
1224
1264
|
|
|
1225
|
-
A pre-commit hook
|
|
1226
|
-
|
|
1265
|
+
A pre-commit hook (`.husky/pre-commit`, installed by `npm install`) checks
|
|
1266
|
+
only what is staged, in well under two seconds: `prettier --check`, failing
|
|
1267
|
+
with the `npx prettier --write …` command that fixes it, and a scan of the
|
|
1268
|
+
added lines for token-shaped secrets with the merge gate's own rule
|
|
1269
|
+
(`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
|
|
1270
|
+
`lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
|
|
1271
|
+
token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
|
|
1272
|
+
whole. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
|
|
1273
|
+
until `npm install` runs in them. Typecheck, lint and tests are left to CI.
|
|
1274
|
+
In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
|
|
1227
1275
|
which also holds the app's first-load JavaScript to a budget
|
|
1228
1276
|
(`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
|
|
1229
1277
|
it), and fails a pull request whose body has no Code review section for its head
|
|
@@ -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
|
+
}
|
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
|
|
|
@@ -35,6 +35,7 @@ import { settingsUiActions } from "@/stores/settingsUi";
|
|
|
35
35
|
import { usageUiActions } from "@/stores/usageUi";
|
|
36
36
|
import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
|
|
37
37
|
import { LoadGauge } from "./LoadGauge";
|
|
38
|
+
import { AwayToggle } from "@/components/Away/AwayToggle";
|
|
38
39
|
import { useSchedulesGlance } from "./useSchedulesGlance";
|
|
39
40
|
import { cn } from "@/lib/utils";
|
|
40
41
|
|
|
@@ -67,6 +68,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
67
68
|
<LoadGauge />
|
|
68
69
|
</div>
|
|
69
70
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
71
|
+
<AwayToggle />
|
|
70
72
|
<ADropdownMenu
|
|
71
73
|
trigger={
|
|
72
74
|
<Button
|