@saadnvd1/agent-os 0.3.794 → 0.3.807
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/app/api/busy/route.ts +2 -2
- package/app/api/chat-views/[sessionId]/route.ts +19 -0
- package/app/api/chat-views/route.test.ts +79 -0
- package/app/api/chat-views/route.ts +8 -0
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/app/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +74 -59
- package/components/Chat/NewDivider.tsx +15 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Chat/session-link.ts +7 -4
- package/components/Chat/useFind.ts +50 -1
- package/components/Chat/usePosition.ts +363 -0
- package/components/CodeSearchDialog.tsx +90 -0
- package/components/CommandPalette/CommandsBody.tsx +12 -4
- package/components/CommandPalette/useAppCommands.ts +29 -11
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/SessionList/useSessionSwitching.ts +3 -2
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/Shortcuts/PendingKeys.tsx +19 -0
- package/components/Shortcuts/ShortcutsButton.tsx +22 -0
- package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
- package/components/SidebarFooter.tsx +4 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/components/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +541 -0
- package/content/whats-new.json +56 -0
- package/data/chat-views/index.ts +2 -0
- package/data/chat-views/keys.ts +3 -0
- package/data/chat-views/queries.ts +34 -0
- package/data/sessions/pin.ts +0 -1
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/hooks/useSessionUrl.ts +23 -5
- package/lib/bus/index.ts +4 -2
- package/lib/bus/role-alias.ts +4 -2
- package/lib/bus/targets.ts +2 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/escape.ts +8 -4
- package/lib/chat-views/body.ts +41 -0
- package/lib/chat-views/store.ts +67 -0
- package/lib/chat-views/types.ts +20 -0
- package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
- package/lib/db/migrations.ts +21 -0
- package/lib/keybindings/focus.ts +17 -0
- package/lib/keybindings/keymap.ts +120 -0
- package/lib/keybindings/sequence.ts +64 -0
- package/lib/live/service.ts +1 -1
- package/lib/nav/history-mirror.ts +59 -0
- package/lib/nav/history-writer.ts +109 -0
- package/lib/nav/position.ts +67 -0
- package/lib/nav/recent.ts +34 -0
- package/lib/orchestrator/armed.ts +5 -0
- package/lib/palette/sections.ts +12 -6
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/session-url.ts +11 -5
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-access-picker-dark.webp +0 -0
- package/public/tips/chat-access-picker.webp +0 -0
- package/public/tips/chat-attach-files-poster.webp +0 -0
- package/public/tips/chat-attach-files.mp4 +0 -0
- package/public/tips/chat-bookmark-dark.webp +0 -0
- package/public/tips/chat-bookmark.webp +0 -0
- package/public/tips/chat-plan-mode-dark.webp +0 -0
- package/public/tips/chat-plan-mode.webp +0 -0
- package/public/tips/chat-search-poster.webp +0 -0
- package/public/tips/chat-search.mp4 +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/public/tips/chat-tab-suggestion-poster.webp +0 -0
- package/public/tips/chat-tab-suggestion.mp4 +0 -0
- package/public/tips/chat-terminal-handoff-poster.webp +0 -0
- package/public/tips/chat-terminal-handoff.mp4 +0 -0
- package/public/tips/chat-undo-dark.webp +0 -0
- package/public/tips/chat-undo.webp +0 -0
- package/public/tips/export-ai-dark.webp +0 -0
- package/public/tips/export-ai.webp +0 -0
- package/public/tips/net-messages-dark.webp +0 -0
- package/public/tips/net-messages.webp +0 -0
- package/public/tips/recipe-save-dark.webp +0 -0
- package/public/tips/recipe-save.webp +0 -0
- package/public/tips/schedule-first-dark.webp +0 -0
- package/public/tips/schedule-first.webp +0 -0
- package/public/tips/share-check-dark.webp +0 -0
- package/public/tips/share-check.webp +0 -0
- package/public/tips/sidebar-draft-poster.webp +0 -0
- package/public/tips/sidebar-draft.mp4 +0 -0
- package/public/tips/sidebar-groups-dark.webp +0 -0
- package/public/tips/sidebar-groups.webp +0 -0
- package/public/tips/sidebar-load-dark.webp +0 -0
- package/public/tips/sidebar-load.webp +0 -0
- package/public/tips/sidebar-snooze-dark.webp +0 -0
- package/public/tips/sidebar-snooze.webp +0 -0
- package/public/tips/tasks-plan-pieces-dark.webp +0 -0
- package/public/tips/tasks-plan-pieces.webp +0 -0
- package/public/tips/tasks-sign-off-dark.webp +0 -0
- package/public/tips/tasks-sign-off.webp +0 -0
- package/public/tips/tasks-stacks-dark.webp +0 -0
- package/public/tips/tasks-stacks.webp +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/check +2 -2
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/chatJump.ts +2 -6
- package/stores/chatPositions.ts +65 -0
- package/stores/navHistory.ts +98 -0
- package/stores/shortcutsUi.ts +20 -0
- package/stores/tips.ts +14 -0
- package/components/QuickSwitcher.tsx +0 -286
package/lib/db/migrations.ts
CHANGED
|
@@ -26,6 +26,11 @@ export type { Migration } from "./migration-types";
|
|
|
26
26
|
// typecheck, lint and test) and is not committed. A duplicate id or key, or a
|
|
27
27
|
// file whose name does not match its id, throws when this module loads.
|
|
28
28
|
// lib/db/migrations.test.ts enforces the rest.
|
|
29
|
+
//
|
|
30
|
+
// A migration must leave the previous release runnable: an automatic update
|
|
31
|
+
// that isn't healthy rolls the code back but not the database (chat workers
|
|
32
|
+
// keep writing through the restart, so a restore would drop their work). Add
|
|
33
|
+
// columns and tables; don't rename or drop what the last release reads.
|
|
29
34
|
|
|
30
35
|
export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
|
|
31
36
|
const migrationName = (m: Migration): string =>
|
|
@@ -93,6 +98,22 @@ function compareToUpTo(m: Migration, upTo: number | string): number {
|
|
|
93
98
|
// All migrations in order - never modify existing ones, only add new
|
|
94
99
|
export const migrations: Migration[] = buildRegistry(migrationFiles);
|
|
95
100
|
|
|
101
|
+
/** How many of this build's migrations the database has; the update health check reads it. */
|
|
102
|
+
export function migrationStatus(db: Database.Database): {
|
|
103
|
+
known: number;
|
|
104
|
+
applied: number;
|
|
105
|
+
} {
|
|
106
|
+
const rows = db.prepare(`SELECT id, key FROM _migrations`).all() as {
|
|
107
|
+
id: number;
|
|
108
|
+
key: string | null;
|
|
109
|
+
}[];
|
|
110
|
+
const have = new Set(rows.map((r) => r.key ?? String(r.id)));
|
|
111
|
+
return {
|
|
112
|
+
known: migrations.length,
|
|
113
|
+
applied: migrations.filter((m) => have.has(migrationKey(m))).length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
// `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
|
|
97
118
|
// start from an older database).
|
|
98
119
|
export function runMigrations(
|
package/lib/keybindings/focus.ts
CHANGED
|
@@ -27,3 +27,20 @@ export function fieldOwnsKey(target: EventTarget | null): boolean {
|
|
|
27
27
|
if (el.isContentEditable) return (el.textContent ?? "").trim().length > 0;
|
|
28
28
|
return false;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
// Whether a bare letter is free for the app: nothing is being typed into, no
|
|
32
|
+
// terminal or dialog holds the key, and no menu is open.
|
|
33
|
+
export function isFreeTarget(target: EventTarget | null): boolean {
|
|
34
|
+
const el = target as HTMLElement | null;
|
|
35
|
+
if (!el || typeof el.closest !== "function") return true;
|
|
36
|
+
if (el.closest(".xterm")) return false;
|
|
37
|
+
if (el.closest('[role="dialog"],[role="menu"],[role="listbox"]'))
|
|
38
|
+
return false;
|
|
39
|
+
const tag = el.tagName;
|
|
40
|
+
if (tag === "TEXTAREA" || tag === "SELECT") return false;
|
|
41
|
+
if (tag === "INPUT") return NON_TEXT.has((el as HTMLInputElement).type);
|
|
42
|
+
return !(
|
|
43
|
+
el.isContentEditable ||
|
|
44
|
+
el.closest('[contenteditable]:not([contenteditable="false"])')
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Every shortcut the app has, in one table: the keys that act on it, the
|
|
2
|
+
// shortcuts panel and the palette hints all read this. A `keys` string with
|
|
3
|
+
// a space is a sequence (press g, then i); without one it is a chord.
|
|
4
|
+
|
|
5
|
+
export type KeyGroup = "Go to" | "Chats" | "General" | "Browser";
|
|
6
|
+
|
|
7
|
+
export interface KeyEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
keys: string;
|
|
10
|
+
label: string;
|
|
11
|
+
group: KeyGroup;
|
|
12
|
+
// A bare key or sequence: only while nothing is being typed, and off with
|
|
13
|
+
// the single-key switch.
|
|
14
|
+
single?: boolean;
|
|
15
|
+
// The browser acts on it, not the app: listed so it can be found.
|
|
16
|
+
browser?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const KEYMAP: KeyEntry[] = [
|
|
20
|
+
{ id: "go.inbox", keys: "g i", label: "Inbox", group: "Go to", single: true },
|
|
21
|
+
{
|
|
22
|
+
id: "go.bookmarks",
|
|
23
|
+
keys: "g b",
|
|
24
|
+
label: "Bookmarks",
|
|
25
|
+
group: "Go to",
|
|
26
|
+
single: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "go.messages",
|
|
30
|
+
keys: "g m",
|
|
31
|
+
label: "Messages",
|
|
32
|
+
group: "Go to",
|
|
33
|
+
single: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "go.settings",
|
|
37
|
+
keys: "g s",
|
|
38
|
+
label: "Settings",
|
|
39
|
+
group: "Go to",
|
|
40
|
+
single: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "chat.previous",
|
|
44
|
+
keys: "g p",
|
|
45
|
+
label: "Previous chat (flip back and forth)",
|
|
46
|
+
group: "Chats",
|
|
47
|
+
single: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "chat.open",
|
|
51
|
+
keys: "o c",
|
|
52
|
+
label: "Open a chat",
|
|
53
|
+
group: "Chats",
|
|
54
|
+
single: true,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "chat.new",
|
|
58
|
+
keys: "c",
|
|
59
|
+
label: "New chat",
|
|
60
|
+
group: "Chats",
|
|
61
|
+
single: true,
|
|
62
|
+
},
|
|
63
|
+
{ id: "switcher.open", keys: "mod+p", label: "Switch chat", group: "Chats" },
|
|
64
|
+
{
|
|
65
|
+
id: "session.previous",
|
|
66
|
+
keys: "mod+up",
|
|
67
|
+
label: "Chat above in the sidebar",
|
|
68
|
+
group: "Chats",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "session.next",
|
|
72
|
+
keys: "mod+down",
|
|
73
|
+
label: "Chat below in the sidebar",
|
|
74
|
+
group: "Chats",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "palette.open",
|
|
78
|
+
keys: "mod+k",
|
|
79
|
+
label: "Command palette",
|
|
80
|
+
group: "General",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "help.open",
|
|
84
|
+
keys: "?",
|
|
85
|
+
label: "Keyboard shortcuts",
|
|
86
|
+
group: "General",
|
|
87
|
+
single: true,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "history.back",
|
|
91
|
+
keys: "mod+[",
|
|
92
|
+
label: "Back to the chat you came from",
|
|
93
|
+
group: "Browser",
|
|
94
|
+
browser: true,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "history.forward",
|
|
98
|
+
keys: "mod+]",
|
|
99
|
+
label: "Forward",
|
|
100
|
+
group: "Browser",
|
|
101
|
+
browser: true,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
export const keysOf = (id: string): string => {
|
|
106
|
+
const e = KEYMAP.find((k) => k.id === id);
|
|
107
|
+
if (!e) throw new Error(`No shortcut "${id}"`);
|
|
108
|
+
return e.keys;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const isSequence = (keys: string) => keys.includes(" ");
|
|
112
|
+
|
|
113
|
+
/** The table's entries a search finds, in table order; no search finds all. */
|
|
114
|
+
export function searchKeymap(query: string): KeyEntry[] {
|
|
115
|
+
const q = query.trim().toLowerCase();
|
|
116
|
+
if (!q) return KEYMAP;
|
|
117
|
+
return KEYMAP.filter((k) =>
|
|
118
|
+
`${k.label} ${k.keys} ${k.group}`.toLowerCase().includes(q)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Two-key sequences (g then i). The matcher is fed every plain key press; it
|
|
2
|
+
// says whether the key started a sequence, finished one, or is nobody's.
|
|
3
|
+
|
|
4
|
+
export const SEQUENCE_MS = 1500;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The key a bare shortcut sees, or null. The hints print letters in
|
|
8
|
+
* capitals, so Shift+G counts as g; other shifted keys are nobody's but "?".
|
|
9
|
+
*/
|
|
10
|
+
export function plainKey(key: string, shift: boolean): string | null {
|
|
11
|
+
if (key === "?" || /^[a-z]$/i.test(key)) return key.toLowerCase();
|
|
12
|
+
return shift ? null : key;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SequenceStep =
|
|
16
|
+
| { kind: "none" }
|
|
17
|
+
| { kind: "pending"; first: string }
|
|
18
|
+
| { kind: "match"; id: string };
|
|
19
|
+
|
|
20
|
+
export class SequenceMatcher {
|
|
21
|
+
private first: string | null = null;
|
|
22
|
+
private at = 0;
|
|
23
|
+
private starts: Set<string>;
|
|
24
|
+
private pairs: Map<string, string>;
|
|
25
|
+
|
|
26
|
+
/** `sequences` maps "g i" to the id it runs. */
|
|
27
|
+
constructor(
|
|
28
|
+
sequences: Record<string, string>,
|
|
29
|
+
private now: () => number = Date.now
|
|
30
|
+
) {
|
|
31
|
+
this.pairs = new Map(
|
|
32
|
+
Object.entries(sequences).map(([k, id]) => [k.toLowerCase(), id])
|
|
33
|
+
);
|
|
34
|
+
this.starts = new Set([...this.pairs.keys()].map((k) => k.split(" ")[0]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A key was pressed with no modifier but maybe shift. */
|
|
38
|
+
feed(key: string): SequenceStep {
|
|
39
|
+
const k = key.toLowerCase();
|
|
40
|
+
if (this.first !== null && this.now() - this.at <= SEQUENCE_MS) {
|
|
41
|
+
const id = this.pairs.get(`${this.first} ${k}`);
|
|
42
|
+
this.first = null;
|
|
43
|
+
if (id) return { kind: "match", id };
|
|
44
|
+
}
|
|
45
|
+
this.first = null;
|
|
46
|
+
if (this.starts.has(k)) {
|
|
47
|
+
this.first = k;
|
|
48
|
+
this.at = this.now();
|
|
49
|
+
return { kind: "pending", first: k };
|
|
50
|
+
}
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether the first key of a sequence is waiting for its second. */
|
|
55
|
+
pending(): string | null {
|
|
56
|
+
return this.first !== null && this.now() - this.at <= SEQUENCE_MS
|
|
57
|
+
? this.first
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
reset(): void {
|
|
62
|
+
this.first = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/lib/live/service.ts
CHANGED
|
@@ -259,7 +259,7 @@ export async function changeCode(
|
|
|
259
259
|
id: string,
|
|
260
260
|
next: { generate: true } | { code: string } | null
|
|
261
261
|
): Promise<void> {
|
|
262
|
-
|
|
262
|
+
runtimeFor(id);
|
|
263
263
|
let code: string | null = null;
|
|
264
264
|
if (next && "code" in next) {
|
|
265
265
|
const why = codeRefusal(next.code);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page remembers of the history entries it made, for the menu behind
|
|
3
|
+
* the back button: the browser won't list its own history. Entries are keyed
|
|
4
|
+
* by the `index` also kept in each entry's state, so a reload or a back
|
|
5
|
+
* button still finds its place.
|
|
6
|
+
*/
|
|
7
|
+
export interface MirrorEntry {
|
|
8
|
+
index: number;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Mirror {
|
|
14
|
+
entries: MirrorEntry[];
|
|
15
|
+
current: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const MAX_MIRROR = 100;
|
|
19
|
+
|
|
20
|
+
export const emptyMirror = (): Mirror => ({ entries: [], current: -1 });
|
|
21
|
+
|
|
22
|
+
/** Standing on an entry that is new (a push) or known (replace or reload):
|
|
23
|
+
* a push drops the forward entries, as the browser does. */
|
|
24
|
+
export function visit(
|
|
25
|
+
m: Mirror,
|
|
26
|
+
entry: MirrorEntry,
|
|
27
|
+
how: "push" | "replace"
|
|
28
|
+
): Mirror {
|
|
29
|
+
const kept =
|
|
30
|
+
how === "push"
|
|
31
|
+
? m.entries.filter((e) => e.index < entry.index)
|
|
32
|
+
: m.entries.filter((e) => e.index !== entry.index);
|
|
33
|
+
const entries = [...kept, entry]
|
|
34
|
+
.sort((a, b) => a.index - b.index)
|
|
35
|
+
.slice(-MAX_MIRROR);
|
|
36
|
+
return { entries, current: entry.index };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Back or forward landed on the entry with this index. */
|
|
40
|
+
export const moveTo = (m: Mirror, index: number): Mirror =>
|
|
41
|
+
m.entries.some((e) => e.index === index) ? { ...m, current: index } : m;
|
|
42
|
+
|
|
43
|
+
export const earlier = (m: Mirror): MirrorEntry[] =>
|
|
44
|
+
m.entries.filter((e) => e.index < m.current).reverse();
|
|
45
|
+
|
|
46
|
+
export const later = (m: Mirror): MirrorEntry[] =>
|
|
47
|
+
m.entries.filter((e) => e.index > m.current);
|
|
48
|
+
|
|
49
|
+
/** A renamed chat reads under its new name in the menu. */
|
|
50
|
+
export const renameIn = (
|
|
51
|
+
m: Mirror,
|
|
52
|
+
sessionId: string,
|
|
53
|
+
name: string
|
|
54
|
+
): Mirror => ({
|
|
55
|
+
...m,
|
|
56
|
+
entries: m.entries.map((e) =>
|
|
57
|
+
e.sessionId === sessionId ? { ...e, name } : e
|
|
58
|
+
),
|
|
59
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Position } from "./position";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one place the page writes browser history. A chat switch or a bookmark
|
|
5
|
+
* pushes an entry; where the reader is in the chat goes into the CURRENT
|
|
6
|
+
* entry's state with replaceState, at most once per POSITION_EVERY_MS, so
|
|
7
|
+
* back lands on the message that was on screen without filling the history
|
|
8
|
+
* (Safari throws after 100 calls in 30 seconds, Firefox throws on a burst,
|
|
9
|
+
* Chrome drops them silently).
|
|
10
|
+
*
|
|
11
|
+
* Next patches pushState/replaceState to carry its own keys in the state it
|
|
12
|
+
* is given, so the state is always the current one with `aos` replaced.
|
|
13
|
+
*/
|
|
14
|
+
export interface NavState {
|
|
15
|
+
v: 1;
|
|
16
|
+
/** Counts up with every push; the menu's key for the entry. */
|
|
17
|
+
index: number;
|
|
18
|
+
sessionId: string | null;
|
|
19
|
+
pos?: Position;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HistoryLike {
|
|
23
|
+
state: unknown;
|
|
24
|
+
pushState(state: unknown, unused: string, url?: string): void;
|
|
25
|
+
replaceState(state: unknown, unused: string, url?: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const POSITION_EVERY_MS = 2000;
|
|
29
|
+
// Position writes stop, navigation never does, when a burst gets this close
|
|
30
|
+
// to the limits above.
|
|
31
|
+
const BURST_WINDOW_MS = 30_000;
|
|
32
|
+
const BURST_MAX = 50;
|
|
33
|
+
|
|
34
|
+
export function navStateOf(state: unknown): NavState | null {
|
|
35
|
+
const aos = (state as { aos?: NavState } | null)?.aos;
|
|
36
|
+
return aos && aos.v === 1 && typeof aos.index === "number" ? aos : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const withNav = (state: unknown, aos: NavState) => ({
|
|
40
|
+
...(state && typeof state === "object" ? state : {}),
|
|
41
|
+
aos,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export class HistoryWriter {
|
|
45
|
+
private pending: { sessionId: string; pos: Position } | null = null;
|
|
46
|
+
private lastWrite = -Infinity;
|
|
47
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
private writes: number[] = [];
|
|
49
|
+
|
|
50
|
+
constructor(private h: HistoryLike) {}
|
|
51
|
+
|
|
52
|
+
/** The state of the entry the page is on. */
|
|
53
|
+
current(): NavState | null {
|
|
54
|
+
return navStateOf(this.h.state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where the reader is now; written into the current entry, coalesced. */
|
|
58
|
+
setPosition(sessionId: string, pos: Position): void {
|
|
59
|
+
this.pending = { sessionId, pos };
|
|
60
|
+
if (this.timer) return;
|
|
61
|
+
const wait = this.lastWrite + POSITION_EVERY_MS - Date.now();
|
|
62
|
+
if (wait <= 0) this.flush();
|
|
63
|
+
else this.timer = setTimeout(() => this.flush(), wait);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Writes the waiting position now, if it belongs to the current entry. */
|
|
67
|
+
flush(): void {
|
|
68
|
+
if (this.timer) clearTimeout(this.timer);
|
|
69
|
+
this.timer = null;
|
|
70
|
+
const p = this.pending;
|
|
71
|
+
this.pending = null;
|
|
72
|
+
const cur = this.current();
|
|
73
|
+
if (!p || !cur || cur.sessionId !== p.sessionId) return;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
this.writes = this.writes.filter((t) => now - t < BURST_WINDOW_MS);
|
|
76
|
+
if (this.writes.length >= BURST_MAX) return;
|
|
77
|
+
this.writes.push(now);
|
|
78
|
+
this.lastWrite = now;
|
|
79
|
+
this.h.replaceState(withNav(this.h.state, { ...cur, pos: p.pos }), "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A new entry for a chat. The one being left keeps its position first. */
|
|
83
|
+
push(href: string, sessionId: string | null): NavState {
|
|
84
|
+
this.flush();
|
|
85
|
+
const cur = this.current();
|
|
86
|
+
const next: NavState = {
|
|
87
|
+
v: 1,
|
|
88
|
+
index: (cur?.index ?? 0) + 1,
|
|
89
|
+
sessionId,
|
|
90
|
+
};
|
|
91
|
+
this.h.pushState(withNav(null, next), "", href);
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The same entry at another address (a rename), keeping its state. A page
|
|
96
|
+
* that has none yet gets its first, index 0. */
|
|
97
|
+
replace(href: string, sessionId: string | null): NavState {
|
|
98
|
+
const cur = this.current();
|
|
99
|
+
const next: NavState = cur
|
|
100
|
+
? { ...cur, sessionId }
|
|
101
|
+
: { v: 1, index: 0, sessionId };
|
|
102
|
+
// A different chat takes the entry over: its position was another's.
|
|
103
|
+
if (cur && cur.sessionId !== sessionId) delete next.pos;
|
|
104
|
+
if (this.pending && this.pending.sessionId !== sessionId)
|
|
105
|
+
this.pending = null;
|
|
106
|
+
this.h.replaceState(withNav(this.h.state, next), "", href);
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ChatItem } from "@/lib/chat/events";
|
|
2
|
+
import type { ChatView } from "@/lib/chat-views/types";
|
|
3
|
+
|
|
4
|
+
/** Where a reader is in a chat: the block in view's key, how far its top is
|
|
5
|
+
* from the top of the list, and whether they follow the latest output. */
|
|
6
|
+
export interface Position {
|
|
7
|
+
block: string | null;
|
|
8
|
+
offsetPx: number;
|
|
9
|
+
atEnd: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RowRect {
|
|
13
|
+
key: string;
|
|
14
|
+
top: number;
|
|
15
|
+
bottom: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first row whose bottom is below the top of the list, and how far its
|
|
19
|
+
* top sits from there (negative when it is partly scrolled off). */
|
|
20
|
+
export function anchorOf(
|
|
21
|
+
listTop: number,
|
|
22
|
+
rows: RowRect[],
|
|
23
|
+
atEnd: boolean
|
|
24
|
+
): Position {
|
|
25
|
+
if (atEnd) return { block: null, offsetPx: 0, atEnd: true };
|
|
26
|
+
const row = rows.find((r) => r.bottom > listTop + 1);
|
|
27
|
+
if (!row) return { block: null, offsetPx: 0, atEnd: true };
|
|
28
|
+
return {
|
|
29
|
+
block: row.key,
|
|
30
|
+
offsetPx: Math.round(row.top - listTop),
|
|
31
|
+
atEnd: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type OpenAt =
|
|
36
|
+
| { kind: "latest" }
|
|
37
|
+
| { kind: "new"; itemId: string; hasBefore: boolean }
|
|
38
|
+
| { kind: "position"; block: string; offsetPx: number };
|
|
39
|
+
|
|
40
|
+
interface OpenInput {
|
|
41
|
+
/** What the server remembers about this chat; none when never opened. */
|
|
42
|
+
view: Pick<ChatView, "itemId" | "offsetPx" | "atEnd" | "viewedAt"> | null;
|
|
43
|
+
/** The loaded items, oldest first. */
|
|
44
|
+
items: Pick<ChatItem, "id" | "createdAt">[];
|
|
45
|
+
/** Opened from a notification, Needs you or the Inbox. */
|
|
46
|
+
latest: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where a chat opens when nothing asked for a particular message:
|
|
51
|
+
* - from a notification, Needs you or the Inbox: the latest;
|
|
52
|
+
* - with items newer than the last time it was open: the first new one;
|
|
53
|
+
* - otherwise where it was left, unless that was the end;
|
|
54
|
+
* - never opened: the latest.
|
|
55
|
+
*/
|
|
56
|
+
export function decideOpen({ view, items, latest }: OpenInput): OpenAt {
|
|
57
|
+
if (latest || !view) return { kind: "latest" };
|
|
58
|
+
const firstNew = items.findIndex((i) => i.createdAt > view.viewedAt);
|
|
59
|
+
if (firstNew !== -1)
|
|
60
|
+
return {
|
|
61
|
+
kind: "new",
|
|
62
|
+
itemId: items[firstNew].id,
|
|
63
|
+
hasBefore: firstNew > 0,
|
|
64
|
+
};
|
|
65
|
+
if (view.atEnd || !view.itemId) return { kind: "latest" };
|
|
66
|
+
return { kind: "position", block: view.itemId, offsetPx: view.offsetPx };
|
|
67
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Which chat was open before this one, and chats most-recently-opened first.
|
|
2
|
+
// "Opened" is the chat's viewed_at, which the page stamps whenever a chat is
|
|
3
|
+
// shown, so it is the same on every device.
|
|
4
|
+
|
|
5
|
+
/** When each chat was last opened, by session id (epoch ms). */
|
|
6
|
+
export type Opened = Record<string, number | undefined>;
|
|
7
|
+
|
|
8
|
+
/** Most recently opened first; never-opened chats keep their order after. */
|
|
9
|
+
export function recentFirst<T extends { id: string }>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
opened: Opened
|
|
12
|
+
): T[] {
|
|
13
|
+
return items
|
|
14
|
+
.map((item, i) => ({ item, i, at: opened[item.id] ?? -1 }))
|
|
15
|
+
.sort((a, b) => b.at - a.at || a.i - b.i)
|
|
16
|
+
.map((x) => x.item);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The chat opened just before the current one, among those still open. */
|
|
20
|
+
export function previousChatId(
|
|
21
|
+
ids: readonly string[],
|
|
22
|
+
opened: Opened,
|
|
23
|
+
currentId: string | null | undefined
|
|
24
|
+
): string | null {
|
|
25
|
+
let best: string | null = null;
|
|
26
|
+
let bestAt = -1;
|
|
27
|
+
for (const id of ids) {
|
|
28
|
+
const at = opened[id];
|
|
29
|
+
if (id === currentId || at === undefined || at <= bestAt) continue;
|
|
30
|
+
best = id;
|
|
31
|
+
bestAt = at;
|
|
32
|
+
}
|
|
33
|
+
return best;
|
|
34
|
+
}
|
|
@@ -30,6 +30,7 @@ import { isFast } from "../project-tier/getProjectTier";
|
|
|
30
30
|
import { workspaceTask } from "./targets";
|
|
31
31
|
import { markStatusDirty } from "../status/dirty";
|
|
32
32
|
import { setPr } from "../db/session-parts";
|
|
33
|
+
import { noteAutoArmed } from "./fast-lane-state";
|
|
33
34
|
|
|
34
35
|
// Judged again at least this often, for what the fingerprint can't see.
|
|
35
36
|
export const ARMED_RECHECK_MS = 5 * 60 * 1000;
|
|
@@ -140,6 +141,10 @@ async function armedNow(workspaceId: string, task: Session): Promise<string> {
|
|
|
140
141
|
export function disarm(workspaceId: string, ref: string): string {
|
|
141
142
|
const task = workspaceTask(workspaceId, ref);
|
|
142
143
|
setArmed(task.id, false);
|
|
144
|
+
// A fast task not yet auto-armed would be armed on the next pass; mark
|
|
145
|
+
// its PR as handled so the disarm sticks either way.
|
|
146
|
+
if (isFast(task) && task.pr_number)
|
|
147
|
+
noteAutoArmed(task.id, workspaceId, task.pr_number);
|
|
143
148
|
return `Disarmed ${task.name}: it merges only through sign_off now.`;
|
|
144
149
|
}
|
|
145
150
|
|
package/lib/palette/sections.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface Section {
|
|
|
18
18
|
export const PER_TAB = 5;
|
|
19
19
|
export const RECENT = 5;
|
|
20
20
|
const NEEDS_YOU = 5;
|
|
21
|
+
// The hint on the chat on show, which a switcher skips past.
|
|
22
|
+
export const CURRENT = "Current";
|
|
21
23
|
const SUGGESTED = 6;
|
|
22
24
|
|
|
23
25
|
const label = (tab: PaletteTab) =>
|
|
@@ -61,11 +63,10 @@ export function tabItems(
|
|
|
61
63
|
usage: Usage,
|
|
62
64
|
now: number
|
|
63
65
|
): PaletteCommand[] {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
).map((m) => m.c);
|
|
66
|
+
const inTab = commands.filter((c) => tabOf(c) === tab);
|
|
67
|
+
// Sessions read most-recent-first (the order they were registered in).
|
|
68
|
+
if (tab === "sessions" && !query.trim()) return inTab;
|
|
69
|
+
return scoreCommands(inTab, query, boostFor(usage, now)).map((m) => m.c);
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// The short list All opens on: the last things run, sessions that need the
|
|
@@ -99,13 +100,18 @@ export function emptySections(
|
|
|
99
100
|
.filter((c): c is PaletteCommand => !!c),
|
|
100
101
|
RECENT
|
|
101
102
|
);
|
|
103
|
+
const recentChats = take(
|
|
104
|
+
commands.filter((c) => tabOf(c) === "sessions" && c.hint !== CURRENT),
|
|
105
|
+
RECENT
|
|
106
|
+
);
|
|
102
107
|
const sections: Section[] = [
|
|
108
|
+
{ name: "Recent chats", items: recentChats },
|
|
103
109
|
{ name: "Recent", items: recent },
|
|
104
110
|
{ name: "Needs you / running", items: busy },
|
|
105
111
|
{ name: "This session", items: here },
|
|
106
112
|
];
|
|
107
113
|
// A first run has no history: offer the commands that start things.
|
|
108
|
-
if (recent.length + busy.length + here.length < 4)
|
|
114
|
+
if (recent.length + busy.length + here.length + recentChats.length < 4)
|
|
109
115
|
sections.push({
|
|
110
116
|
name: "Suggested",
|
|
111
117
|
items: take(
|
package/lib/recipes/timer.ts
CHANGED
|
@@ -67,7 +67,9 @@ export function setTimer(
|
|
|
67
67
|
if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
|
|
68
68
|
const bad = cronError(cron);
|
|
69
69
|
if (bad) throw new Error(bad);
|
|
70
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
|
|
72
|
+
)
|
|
71
73
|
throw new Error("A recipe runs at most once an hour");
|
|
72
74
|
const enabled = input.enabled ?? true;
|
|
73
75
|
if (!existing) {
|
package/lib/schedules/cron.ts
CHANGED
|
@@ -261,16 +261,25 @@ export const MIN_GAP_MINUTES = 60;
|
|
|
261
261
|
// A message to a session, at most this often.
|
|
262
262
|
export const MESSAGE_MIN_GAP_MINUTES = 10;
|
|
263
263
|
|
|
264
|
-
// The shortest gap between runs over the next few weeks of them.
|
|
264
|
+
// The shortest gap between runs over the next few weeks of them. With
|
|
265
|
+
// `stopBelow`, it returns as soon as a gap under it turns up: "every minute"
|
|
266
|
+
// then costs two searches, not two hundred.
|
|
265
267
|
export function minGapMinutes(
|
|
266
268
|
cron: string,
|
|
267
269
|
timezone: string,
|
|
268
|
-
from = Date.now()
|
|
270
|
+
from = Date.now(),
|
|
271
|
+
stopBelow = 0
|
|
269
272
|
): number {
|
|
270
|
-
const
|
|
273
|
+
const parsed = parseCron(cron);
|
|
271
274
|
let min = Infinity;
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
let prev = nextRun(parsed, from, timezone);
|
|
276
|
+
for (let i = 1; i < 200 && prev !== null; i++) {
|
|
277
|
+
const next = nextRun(parsed, prev, timezone);
|
|
278
|
+
if (next === null) break;
|
|
279
|
+
min = Math.min(min, (next - prev) / 60_000);
|
|
280
|
+
if (min < stopBelow) break;
|
|
281
|
+
prev = next;
|
|
282
|
+
}
|
|
274
283
|
return min;
|
|
275
284
|
}
|
|
276
285
|
|
package/lib/schedules/store.ts
CHANGED
|
@@ -298,7 +298,7 @@ function validate(
|
|
|
298
298
|
if (
|
|
299
299
|
!oneOff &&
|
|
300
300
|
(input.kind === "task" || input.kind === "session") &&
|
|
301
|
-
minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
|
|
301
|
+
minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
|
|
302
302
|
)
|
|
303
303
|
throw new Error(
|
|
304
304
|
"A task or session schedule runs at most once an hour; for more often, post to the orchestrator"
|
|
@@ -309,7 +309,8 @@ function validate(
|
|
|
309
309
|
if (
|
|
310
310
|
!oneOff &&
|
|
311
311
|
(message || agentCheckIn) &&
|
|
312
|
-
minGapMinutes(cron, timezone) <
|
|
312
|
+
minGapMinutes(cron, timezone, Date.now(), MESSAGE_MIN_GAP_MINUTES) <
|
|
313
|
+
MESSAGE_MIN_GAP_MINUTES
|
|
313
314
|
)
|
|
314
315
|
throw new Error(
|
|
315
316
|
`A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
|
package/lib/security/demo.ts
CHANGED
|
@@ -150,6 +150,8 @@ const READS = [
|
|
|
150
150
|
"demo",
|
|
151
151
|
// The changelog: a static file, no agent.
|
|
152
152
|
"whats-new",
|
|
153
|
+
// Tip of the Day: reading is a static file plus two settings.
|
|
154
|
+
"tips",
|
|
153
155
|
].map(route);
|
|
154
156
|
|
|
155
157
|
// The only writes, each a database write and nothing more in demo mode:
|