@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { TipsView } from "@/lib/tips/schema";
|
|
3
|
+
|
|
4
|
+
export const tipsKeys = { all: ["tips"] as const };
|
|
5
|
+
|
|
6
|
+
// The browser's own date: the tip changes at the user's midnight.
|
|
7
|
+
const localDay = () => new Date().toLocaleDateString("sv");
|
|
8
|
+
|
|
9
|
+
async function send(init?: { body: object }): Promise<TipsView> {
|
|
10
|
+
const res = await fetch(
|
|
11
|
+
init ? "/api/tips" : `/api/tips?day=${localDay()}`,
|
|
12
|
+
init && {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify(init.body),
|
|
16
|
+
}
|
|
17
|
+
).catch(() => {
|
|
18
|
+
throw new Error("Couldn't reach AgentOS. Try again.");
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) throw new Error(`Tips failed (${res.status})`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The tips file only changes with an update; the day can change under an open tab.
|
|
25
|
+
export const useTips = () =>
|
|
26
|
+
useQuery({
|
|
27
|
+
queryKey: tipsKeys.all,
|
|
28
|
+
queryFn: () => send(),
|
|
29
|
+
staleTime: 10 * 60_000,
|
|
30
|
+
refetchOnWindowFocus: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function useTipsMutation<V>(body: (v: V) => object) {
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
return useMutation({
|
|
36
|
+
mutationFn: (v: V) => send({ body: body(v) }),
|
|
37
|
+
onSuccess: (view) => queryClient.setQueryData(tipsKeys.all, view),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const useMarkTipSeen = () =>
|
|
42
|
+
useTipsMutation<string>((id) => ({
|
|
43
|
+
action: "seen",
|
|
44
|
+
ids: [id],
|
|
45
|
+
day: localDay(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
export const useSetTipsOff = () =>
|
|
49
|
+
useTipsMutation<boolean>((off) => ({ action: "off", off }));
|
package/data/updates/index.ts
CHANGED
|
@@ -61,3 +61,8 @@ export const useUpdateBannerSetting = () =>
|
|
|
61
61
|
useUpdateMutation<boolean>((banner) =>
|
|
62
62
|
send("PUT", "/api/updates/settings", { banner })
|
|
63
63
|
);
|
|
64
|
+
|
|
65
|
+
export const useAutoUpdateSetting = () =>
|
|
66
|
+
useUpdateMutation<{ auto?: boolean; idleMinutes?: number }>((patch) =>
|
|
67
|
+
send("PUT", "/api/updates/settings", patch)
|
|
68
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useChatViews } from "@/data/chat-views";
|
|
3
|
+
import { chatPositionsActions } from "@/stores/chatPositions";
|
|
4
|
+
|
|
5
|
+
/** Hands the server's reading positions to the page; chats wait for them to open where they were left. */
|
|
6
|
+
export function useChatViewsSync(): void {
|
|
7
|
+
const { data, isError } = useChatViews();
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (data) chatPositionsActions.hydrate(data);
|
|
10
|
+
else if (isError) chatPositionsActions.giveUp();
|
|
11
|
+
}, [data, isError]);
|
|
12
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Keyboard, MessageSquare, Repeat } from "lucide-react";
|
|
4
|
+
import type { Session } from "@/lib/db";
|
|
5
|
+
import { chordHint } from "@/lib/keybindings/chord";
|
|
6
|
+
import { keysOf } from "@/lib/keybindings/keymap";
|
|
7
|
+
import { previousChatId } from "@/lib/nav/recent";
|
|
8
|
+
import { useNavKeys, useSingleKeys } from "@/hooks/useNavKeys";
|
|
9
|
+
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
10
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
11
|
+
import { chatOpened } from "@/stores/chatPositions";
|
|
12
|
+
import { newDraft } from "@/stores/drafts";
|
|
13
|
+
import { inboxUiActions } from "@/stores/inboxUi";
|
|
14
|
+
import { paletteActions, paletteUi } from "@/stores/palette";
|
|
15
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
16
|
+
import { shortcutsUiActions } from "@/stores/shortcutsUi";
|
|
17
|
+
|
|
18
|
+
/** The chats ⌘P, O C and the like switch between, as the palette's Sessions tab. */
|
|
19
|
+
function openSwitcher() {
|
|
20
|
+
if (paletteUi.open && paletteUi.tab === "sessions")
|
|
21
|
+
return paletteActions.setOpen(false);
|
|
22
|
+
paletteActions.open();
|
|
23
|
+
paletteActions.setTab("sessions");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What each shortcut in keymap.ts does, and the palette commands that go
|
|
28
|
+
* with them. Opening a chat goes through `select`, so history, position and
|
|
29
|
+
* the address follow as they do for a click.
|
|
30
|
+
*/
|
|
31
|
+
export function useNavActions({
|
|
32
|
+
sessions,
|
|
33
|
+
activeSessionId,
|
|
34
|
+
select,
|
|
35
|
+
}: {
|
|
36
|
+
sessions: Session[];
|
|
37
|
+
activeSessionId: string | null | undefined;
|
|
38
|
+
select: (session: Session) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [singles, setSingles] = useSingleKeys();
|
|
41
|
+
const previous = () => {
|
|
42
|
+
const open = sessions.filter((s) => !s.archived_at);
|
|
43
|
+
const id = previousChatId(
|
|
44
|
+
open.map((s) => s.id),
|
|
45
|
+
chatOpened,
|
|
46
|
+
activeSessionId
|
|
47
|
+
);
|
|
48
|
+
const session = open.find((s) => s.id === id);
|
|
49
|
+
if (session) select(session);
|
|
50
|
+
};
|
|
51
|
+
const actions = {
|
|
52
|
+
"palette.open": () =>
|
|
53
|
+
paletteUi.open ? paletteActions.setOpen(false) : paletteActions.open(),
|
|
54
|
+
"switcher.open": openSwitcher,
|
|
55
|
+
"chat.open": openSwitcher,
|
|
56
|
+
"chat.previous": previous,
|
|
57
|
+
"chat.new": () => newDraft({ kind: "current" }),
|
|
58
|
+
"go.inbox": () => inboxUiActions.open(),
|
|
59
|
+
"go.messages": () => inboxUiActions.open("messages"),
|
|
60
|
+
"go.bookmarks": () => bookmarksUiActions.open(activeSessionId ?? null),
|
|
61
|
+
"go.settings": () => settingsUiActions.open(),
|
|
62
|
+
"help.open": shortcutsUiActions.open,
|
|
63
|
+
};
|
|
64
|
+
useNavKeys(actions);
|
|
65
|
+
usePaletteCommands("nav", [
|
|
66
|
+
{
|
|
67
|
+
id: "nav.switch",
|
|
68
|
+
title: "Switch chat",
|
|
69
|
+
group: "Go to",
|
|
70
|
+
hint: chordHint(keysOf("switcher.open")),
|
|
71
|
+
keywords: ["recent", "open chat", "session", "switcher"],
|
|
72
|
+
icon: MessageSquare,
|
|
73
|
+
run: openSwitcher,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "nav.previous",
|
|
77
|
+
title: "Previous chat",
|
|
78
|
+
group: "Go to",
|
|
79
|
+
hint: "G P",
|
|
80
|
+
keywords: ["flip", "alternate", "last", "back"],
|
|
81
|
+
icon: Repeat,
|
|
82
|
+
run: previous,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "nav.shortcuts",
|
|
86
|
+
title: "Keyboard shortcuts",
|
|
87
|
+
group: "Go to",
|
|
88
|
+
hint: "?",
|
|
89
|
+
keywords: ["keys", "help", "hotkeys"],
|
|
90
|
+
icon: Keyboard,
|
|
91
|
+
run: shortcutsUiActions.open,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "nav.single-keys",
|
|
95
|
+
title: singles
|
|
96
|
+
? "Turn off single-key shortcuts"
|
|
97
|
+
: "Turn on single-key shortcuts",
|
|
98
|
+
group: "Settings",
|
|
99
|
+
keywords: ["keyboard", "letters", "vim", "linear"],
|
|
100
|
+
icon: Keyboard,
|
|
101
|
+
run: () => setSingles(!singles),
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { useKeybindings } from "@/hooks/useKeybindings";
|
|
5
|
+
import { useStoredFlag } from "@/hooks/useStoredFlag";
|
|
6
|
+
import { isFreeTarget } from "@/lib/keybindings/focus";
|
|
7
|
+
import { KEYMAP, isSequence, keysOf } from "@/lib/keybindings/keymap";
|
|
8
|
+
import {
|
|
9
|
+
SEQUENCE_MS,
|
|
10
|
+
SequenceMatcher,
|
|
11
|
+
plainKey,
|
|
12
|
+
} from "@/lib/keybindings/sequence";
|
|
13
|
+
import { shortcutsUiActions } from "@/stores/shortcutsUi";
|
|
14
|
+
|
|
15
|
+
export const SINGLE_KEYS_FLAG = "agentos-single-key-shortcuts";
|
|
16
|
+
|
|
17
|
+
/** The single-key shortcuts' on/off switch, kept per browser. */
|
|
18
|
+
export const useSingleKeys = () => useStoredFlag(SINGLE_KEYS_FLAG, true);
|
|
19
|
+
|
|
20
|
+
export type NavActions = Record<string, () => void>;
|
|
21
|
+
|
|
22
|
+
const SEQUENCES = Object.fromEntries(
|
|
23
|
+
KEYMAP.filter((k) => k.single && isSequence(k.keys)).map((k) => [
|
|
24
|
+
k.keys,
|
|
25
|
+
k.id,
|
|
26
|
+
])
|
|
27
|
+
);
|
|
28
|
+
const BARE = Object.fromEntries(
|
|
29
|
+
KEYMAP.filter((k) => k.single && !isSequence(k.keys)).map((k) => [
|
|
30
|
+
k.keys,
|
|
31
|
+
k.id,
|
|
32
|
+
])
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const CHORD_IDS = ["switcher.open", "palette.open"];
|
|
36
|
+
|
|
37
|
+
const dialogOpen = () =>
|
|
38
|
+
!!document.querySelector('[role="dialog"][data-state="open"]');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The app's keys by id (see keymap.ts): chords through the registry, bare
|
|
42
|
+
* keys and two-key sequences here, only while nothing is being typed into
|
|
43
|
+
* and the single-key switch is on.
|
|
44
|
+
*/
|
|
45
|
+
export function useNavKeys(actions: NavActions) {
|
|
46
|
+
const latest = useRef(actions);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
latest.current = actions;
|
|
49
|
+
});
|
|
50
|
+
const run = (id: string) => latest.current[id]?.();
|
|
51
|
+
const [singles] = useSingleKeys();
|
|
52
|
+
const singlesOn = useRef(singles);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
singlesOn.current = singles;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The sidebar's ⌘↑/⌘↓ are bound by useSessionSwitching, from the same table.
|
|
58
|
+
useKeybindings(
|
|
59
|
+
"nav",
|
|
60
|
+
CHORD_IDS.map((id) => ({ id, keys: keysOf(id), run: () => run(id) }))
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const matcher = new SequenceMatcher(SEQUENCES);
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
const clear = () => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
matcher.reset();
|
|
69
|
+
shortcutsUiActions.setPending(null);
|
|
70
|
+
};
|
|
71
|
+
const onKey = (e: KeyboardEvent) => {
|
|
72
|
+
if (!singlesOn.current) return clear();
|
|
73
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return clear();
|
|
74
|
+
if (!isFreeTarget(e.target) || dialogOpen()) return clear();
|
|
75
|
+
if (e.key === "Escape") return clear();
|
|
76
|
+
if (e.key === "Shift") return;
|
|
77
|
+
const bare = plainKey(e.key, e.shiftKey);
|
|
78
|
+
if (bare === null) return clear();
|
|
79
|
+
const waiting = matcher.pending() !== null;
|
|
80
|
+
const step = matcher.feed(bare);
|
|
81
|
+
if (step.kind === "match") {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
clear();
|
|
84
|
+
run(step.id);
|
|
85
|
+
} else if (step.kind === "pending") {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
shortcutsUiActions.setPending(step.first);
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
timer = setTimeout(clear, SEQUENCE_MS);
|
|
90
|
+
} else {
|
|
91
|
+
clear();
|
|
92
|
+
const id = BARE[bare.toLowerCase()];
|
|
93
|
+
if (id && !waiting && !e.repeat) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
run(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
window.addEventListener("keydown", onKey, true);
|
|
100
|
+
return () => {
|
|
101
|
+
window.removeEventListener("keydown", onKey, true);
|
|
102
|
+
clear();
|
|
103
|
+
};
|
|
104
|
+
}, []);
|
|
105
|
+
}
|
|
@@ -13,7 +13,11 @@ export function useReloadWhenUpdated(view: UpdateView | undefined) {
|
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
if (!run) return;
|
|
15
15
|
if (isActivePhase(run.phase)) watched.current = run.id;
|
|
16
|
-
|
|
16
|
+
// A rollback restarted the server onto the old build: reload onto it too.
|
|
17
|
+
else if (
|
|
18
|
+
(run.phase === "done" || run.phase === "rolled_back") &&
|
|
19
|
+
watched.current === run.id
|
|
20
|
+
)
|
|
17
21
|
window.location.reload();
|
|
18
22
|
}, [run]);
|
|
19
23
|
}
|
package/hooks/useSessionUrl.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type SessionRef,
|
|
11
11
|
} from "@/lib/session-url";
|
|
12
12
|
import { usePanes } from "@/contexts/PaneContext";
|
|
13
|
+
import { navHistoryActions } from "@/stores/navHistory";
|
|
14
|
+
import { chatPositionsActions } from "@/stores/chatPositions";
|
|
13
15
|
|
|
14
16
|
interface Options {
|
|
15
17
|
sessions: Session[];
|
|
@@ -59,6 +61,9 @@ export function useSessionUrl({
|
|
|
59
61
|
};
|
|
60
62
|
});
|
|
61
63
|
|
|
64
|
+
const nameOf = (id: string | null) =>
|
|
65
|
+
(id && latest.current.sessions.find((s) => s.id === id)?.name) || "Chat";
|
|
66
|
+
|
|
62
67
|
// Made on first use, outside render.
|
|
63
68
|
const syncRef = useRef<SessionUrlSync | null>(null);
|
|
64
69
|
const getSync = useCallback(() => {
|
|
@@ -79,8 +84,13 @@ export function useSessionUrl({
|
|
|
79
84
|
syncRef.current = new SessionUrlSync(
|
|
80
85
|
{
|
|
81
86
|
href: () => window.location.href,
|
|
82
|
-
push: (href) =>
|
|
83
|
-
replace: (href) =>
|
|
87
|
+
push: (href, id) => navHistoryActions.push(href, id, nameOf(id)),
|
|
88
|
+
replace: (href, id) => navHistoryActions.replace(href, id, nameOf(id)),
|
|
89
|
+
settle: (id) => {
|
|
90
|
+
const entry = navHistoryActions.currentState();
|
|
91
|
+
if (entry?.sessionId !== id)
|
|
92
|
+
navHistoryActions.replace(window.location.href, id, nameOf(id));
|
|
93
|
+
},
|
|
84
94
|
},
|
|
85
95
|
{
|
|
86
96
|
find: (ref) => pickSession(ref, latest.current.sessions)?.id ?? null,
|
|
@@ -119,8 +129,10 @@ export function useSessionUrl({
|
|
|
119
129
|
// A rename (by hand, or the title a first turn gives it) moves the slug.
|
|
120
130
|
const focusedName = sessions.find((s) => s.id === focusedId)?.name ?? null;
|
|
121
131
|
useEffect(() => {
|
|
122
|
-
if (focusedName
|
|
123
|
-
|
|
132
|
+
if (focusedName === null) return;
|
|
133
|
+
getSync().renamed();
|
|
134
|
+
if (focusedId) navHistoryActions.rename(focusedId, focusedName);
|
|
135
|
+
}, [getSync, focusedName, focusedId]);
|
|
124
136
|
|
|
125
137
|
const ready = hydrated && loaded;
|
|
126
138
|
useEffect(() => {
|
|
@@ -128,7 +140,13 @@ export function useSessionUrl({
|
|
|
128
140
|
}, [getSync, ready]);
|
|
129
141
|
|
|
130
142
|
useEffect(() => {
|
|
131
|
-
const onPop = () =>
|
|
143
|
+
const onPop = () => {
|
|
144
|
+
// Back or forward onto an entry: it remembers where the reader was.
|
|
145
|
+
const entry = navHistoryActions.popped();
|
|
146
|
+
if (entry?.sessionId && entry.pos)
|
|
147
|
+
chatPositionsActions.setTarget(entry.sessionId, entry.pos);
|
|
148
|
+
void getSync().popstate();
|
|
149
|
+
};
|
|
132
150
|
window.addEventListener("popstate", onPop);
|
|
133
151
|
return () => window.removeEventListener("popstate", onPop);
|
|
134
152
|
}, [getSync]);
|
package/lib/bus/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./waits/store";
|
|
40
40
|
import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
|
|
41
41
|
import { groupNames } from "../session-groups/store";
|
|
42
|
+
import { sessionWorkspace } from "../schedules/store";
|
|
42
43
|
|
|
43
44
|
export * from "./format";
|
|
44
45
|
export {
|
|
@@ -103,6 +104,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
103
104
|
const sessions = liveSessions();
|
|
104
105
|
const named = candidates(sessions);
|
|
105
106
|
const caller = sessionById(callerId);
|
|
107
|
+
const callerWorkspace = caller ? sessionWorkspace(caller) : null;
|
|
106
108
|
const orchestrator = heldOrchestrator(caller);
|
|
107
109
|
const badges = waitBadges();
|
|
108
110
|
const groups = groupNames();
|
|
@@ -127,9 +129,9 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
127
129
|
groups.has(s.session_group_id) && {
|
|
128
130
|
group: groups.get(s.session_group_id),
|
|
129
131
|
}),
|
|
130
|
-
...(
|
|
132
|
+
...(callerWorkspace &&
|
|
131
133
|
s.role === "orchestrator" &&
|
|
132
|
-
s.workspace_id ===
|
|
134
|
+
s.workspace_id === callerWorkspace && { yours: true }),
|
|
133
135
|
};
|
|
134
136
|
})
|
|
135
137
|
);
|
package/lib/bus/role-alias.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Session } from "../db";
|
|
9
|
+
import { sessionWorkspace } from "../models/resolve";
|
|
9
10
|
|
|
10
11
|
type RoleSession = Pick<
|
|
11
12
|
Session,
|
|
@@ -56,9 +57,10 @@ export function resolveRoleAlias<T extends RoleSession>(
|
|
|
56
57
|
error: `"@${role}" matches ${found.length} sessions: ${found.map(label).join(", ")}. Use the name or the id`,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
+
const workspace = caller ? sessionWorkspace(caller) : null;
|
|
61
|
+
if (role === "supervisor" && workspace) {
|
|
60
62
|
const orchestrator = others.find(
|
|
61
|
-
(s) => s.role === "orchestrator" && s.workspace_id ===
|
|
63
|
+
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
|
62
64
|
);
|
|
63
65
|
if (orchestrator) return { ok: true, session: orchestrator };
|
|
64
66
|
}
|
package/lib/bus/targets.ts
CHANGED
|
@@ -73,7 +73,8 @@ function ownOrchestrator(
|
|
|
73
73
|
callerId?: string | null
|
|
74
74
|
): Session | undefined {
|
|
75
75
|
if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
|
|
76
|
-
const
|
|
76
|
+
const caller = sessionById(callerId);
|
|
77
|
+
const workspace = caller ? sessionWorkspace(caller) : null;
|
|
77
78
|
if (!workspace) return undefined;
|
|
78
79
|
return sessions.find(
|
|
79
80
|
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
package/lib/busy.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { db } from "./db";
|
|
|
2
2
|
import { runningStarts } from "./simulator";
|
|
3
3
|
|
|
4
4
|
// What a restart would cut off and have to start over: reviews, task
|
|
5
|
-
// setups
|
|
5
|
+
// setups, simulator starts, merges and stack landings run in the server. (Chat turns run in their own workers and
|
|
6
6
|
// outlive it.) Older than half an hour is stuck, not in flight.
|
|
7
7
|
export function inFlight(): {
|
|
8
8
|
reviews: number;
|
|
9
9
|
setups: number;
|
|
10
10
|
simulatorStarts: number;
|
|
11
|
+
merges: number;
|
|
12
|
+
landings: number;
|
|
11
13
|
} {
|
|
12
14
|
const count = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
|
13
15
|
return {
|
|
@@ -22,5 +24,25 @@ export function inFlight(): {
|
|
|
22
24
|
AND created_at > datetime('now', '-30 minutes')`
|
|
23
25
|
),
|
|
24
26
|
simulatorStarts: runningStarts(),
|
|
27
|
+
merges: mergesInFlight(),
|
|
28
|
+
landings: count(
|
|
29
|
+
`SELECT COUNT(*) AS n FROM stacks WHERE status = 'landing'`
|
|
30
|
+
),
|
|
25
31
|
};
|
|
26
32
|
}
|
|
33
|
+
|
|
34
|
+
// Between `gh pr merge` and the tidying after it (lib/tasks/finish.ts keeps
|
|
35
|
+
// both on globalThis, so every bundle sees the same ones).
|
|
36
|
+
function mergesInFlight(): number {
|
|
37
|
+
const g = globalThis as unknown as {
|
|
38
|
+
__agentosSigningOff?: Set<string>;
|
|
39
|
+
__agentosAfterMerge?: Map<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
return new Set([
|
|
42
|
+
...(g.__agentosSigningOff ?? []),
|
|
43
|
+
...(g.__agentosAfterMerge?.keys() ?? []),
|
|
44
|
+
]).size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const inFlightTotal = (w: ReturnType<typeof inFlight>): number =>
|
|
48
|
+
w.reviews + w.setups + w.simulatorStarts + w.merges + w.landings;
|
package/lib/chat/escape.ts
CHANGED
|
@@ -37,13 +37,16 @@ export function escapeInterrupts(
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// What Esc does in a chat: set the composer's suggestion aside, stop the
|
|
40
|
-
// turn, or nothing
|
|
40
|
+
// turn, or, with nothing else to do, leave the composer so the bare-key
|
|
41
|
+
// shortcuts (g i) work; nothing when something on top takes it or it isn't
|
|
42
|
+
// a bare Esc.
|
|
41
43
|
export function escapeAction(
|
|
42
44
|
e: EscapeKey,
|
|
43
45
|
running: boolean,
|
|
44
46
|
overlayOpen: boolean,
|
|
45
|
-
ghostShown: boolean
|
|
46
|
-
|
|
47
|
+
ghostShown: boolean,
|
|
48
|
+
inComposer = false
|
|
49
|
+
): "dismiss" | "interrupt" | "blur" | null {
|
|
47
50
|
const bare =
|
|
48
51
|
e.key === "Escape" &&
|
|
49
52
|
!e.repeat &&
|
|
@@ -51,5 +54,6 @@ export function escapeAction(
|
|
|
51
54
|
!(e.metaKey || e.ctrlKey || e.altKey || e.shiftKey);
|
|
52
55
|
if (!bare || overlayOpen) return null;
|
|
53
56
|
if (ghostShown) return "dismiss";
|
|
54
|
-
|
|
57
|
+
if (running) return "interrupt";
|
|
58
|
+
return inComposer ? "blur" : null;
|
|
55
59
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readCapped } from "../security/read-capped";
|
|
4
|
+
import type { ChatViewInput } from "./types";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 2048;
|
|
7
|
+
|
|
8
|
+
const position = z.object({
|
|
9
|
+
itemId: z.string().min(1).max(200).nullable(),
|
|
10
|
+
offsetPx: z.number().int().min(-100000).max(100000),
|
|
11
|
+
atEnd: z.boolean(),
|
|
12
|
+
// False keeps `viewed_at` as it was: the reader left with newer items below
|
|
13
|
+
// them that they haven't seen.
|
|
14
|
+
advance: z.boolean().optional(),
|
|
15
|
+
});
|
|
16
|
+
const body = z.union([position, z.object({ viewed: z.literal(true) })]);
|
|
17
|
+
|
|
18
|
+
/** The PUT body: a position to save, `null` for "opened just now", or the
|
|
19
|
+
* response that refuses it. */
|
|
20
|
+
export async function readViewBody(
|
|
21
|
+
request: Request
|
|
22
|
+
): Promise<{ input: ChatViewInput | null } | NextResponse> {
|
|
23
|
+
const raw = await readCapped(request, MAX_BODY_BYTES);
|
|
24
|
+
if (raw === null)
|
|
25
|
+
return NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
26
|
+
let json: unknown = null;
|
|
27
|
+
try {
|
|
28
|
+
json = JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
// Not JSON: refused below.
|
|
31
|
+
}
|
|
32
|
+
const parsed = body.safeParse(json);
|
|
33
|
+
if (!parsed.success)
|
|
34
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
35
|
+
return {
|
|
36
|
+
input:
|
|
37
|
+
"viewed" in parsed.data
|
|
38
|
+
? null
|
|
39
|
+
: { ...parsed.data, advance: parsed.data.advance !== false },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import { MAX_VIEWS, type ChatView, type ChatViewInput } from "./types";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
session_id: string;
|
|
6
|
+
item_id: string | null;
|
|
7
|
+
offset_px: number;
|
|
8
|
+
at_end: number;
|
|
9
|
+
viewed_at: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const toView = (r: Row): ChatView => ({
|
|
13
|
+
sessionId: r.session_id,
|
|
14
|
+
itemId: r.item_id,
|
|
15
|
+
offsetPx: r.offset_px,
|
|
16
|
+
atEnd: r.at_end === 1,
|
|
17
|
+
viewedAt: r.viewed_at,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** The most recently viewed chats first. */
|
|
21
|
+
export function listChatViews(limit = MAX_VIEWS): ChatView[] {
|
|
22
|
+
return (
|
|
23
|
+
db
|
|
24
|
+
.prepare(`SELECT * FROM chat_views ORDER BY viewed_at DESC LIMIT ?`)
|
|
25
|
+
.all(limit) as Row[]
|
|
26
|
+
).map(toView);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Saves where the reader is. Without a position it only marks the chat as
|
|
30
|
+
* viewed now (opening it), keeping what was saved. */
|
|
31
|
+
export function saveChatView(
|
|
32
|
+
sessionId: string,
|
|
33
|
+
input: ChatViewInput | null,
|
|
34
|
+
now = Date.now()
|
|
35
|
+
): ChatView {
|
|
36
|
+
if (input) {
|
|
37
|
+
db.prepare(
|
|
38
|
+
`INSERT INTO chat_views (session_id, item_id, offset_px, at_end, viewed_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(session_id) DO UPDATE SET item_id = excluded.item_id,
|
|
41
|
+
offset_px = excluded.offset_px, at_end = excluded.at_end,
|
|
42
|
+
viewed_at = CASE WHEN ? = 1 THEN excluded.viewed_at
|
|
43
|
+
ELSE chat_views.viewed_at END`
|
|
44
|
+
).run(
|
|
45
|
+
sessionId,
|
|
46
|
+
input.itemId,
|
|
47
|
+
input.offsetPx,
|
|
48
|
+
input.atEnd ? 1 : 0,
|
|
49
|
+
now,
|
|
50
|
+
input.advance ? 1 : 0
|
|
51
|
+
);
|
|
52
|
+
} else {
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO chat_views (session_id, viewed_at) VALUES (?, ?)
|
|
55
|
+
ON CONFLICT(session_id) DO UPDATE SET viewed_at = excluded.viewed_at`
|
|
56
|
+
).run(sessionId, now);
|
|
57
|
+
}
|
|
58
|
+
return toView(
|
|
59
|
+
db
|
|
60
|
+
.prepare(`SELECT * FROM chat_views WHERE session_id = ?`)
|
|
61
|
+
.get(sessionId) as Row
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deleteChatView(sessionId: string): void {
|
|
66
|
+
db.prepare(`DELETE FROM chat_views WHERE session_id = ?`).run(sessionId);
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Where a chat was last being read: the first row in view, how far down it
|
|
2
|
+
* sat, and whether the reader was following the latest output. */
|
|
3
|
+
export interface ChatView {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
itemId: string | null;
|
|
6
|
+
offsetPx: number;
|
|
7
|
+
atEnd: boolean;
|
|
8
|
+
/** Epoch ms of the last time the reader had this chat open. */
|
|
9
|
+
viewedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatViewInput {
|
|
13
|
+
itemId: string | null;
|
|
14
|
+
offsetPx: number;
|
|
15
|
+
atEnd: boolean;
|
|
16
|
+
/** Whether this counts as having seen everything up to now. */
|
|
17
|
+
advance: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MAX_VIEWS = 500;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261031T1500_chat_views",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Where each chat was last being read, for the person, on any device. No
|
|
7
|
+
// FK to sessions, like chat_bookmarks; a trigger drops a deleted chat's row.
|
|
8
|
+
// viewed_at is epoch milliseconds, the unit chat items carry.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS chat_views (
|
|
11
|
+
session_id TEXT PRIMARY KEY,
|
|
12
|
+
item_id TEXT,
|
|
13
|
+
offset_px INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
at_end INTEGER NOT NULL DEFAULT 1,
|
|
15
|
+
viewed_at INTEGER NOT NULL
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
db.exec(
|
|
19
|
+
`CREATE INDEX IF NOT EXISTS idx_chat_views_viewed
|
|
20
|
+
ON chat_views(viewed_at DESC)`
|
|
21
|
+
);
|
|
22
|
+
db.exec(`
|
|
23
|
+
CREATE TRIGGER IF NOT EXISTS chat_views_session_deleted
|
|
24
|
+
AFTER DELETE ON sessions
|
|
25
|
+
BEGIN
|
|
26
|
+
DELETE FROM chat_views WHERE session_id = OLD.id;
|
|
27
|
+
END
|
|
28
|
+
`);
|
|
29
|
+
},
|
|
30
|
+
} satisfies Migration;
|