@saadnvd1/agent-os 0.3.799 → 0.3.810
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/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/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +46 -59
- package/components/Chat/NewDivider.tsx +15 -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/Media/MediaViewer.tsx +111 -0
- package/components/Media/viewer.test.ts +26 -0
- package/components/Media/viewer.ts +23 -0
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/SessionList/useSessionSwitching.ts +3 -2
- 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 +2 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/Tips/TipDialog.tsx +1 -1
- package/components/Tips/TipMedia.tsx +38 -3
- package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
- package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
- package/components/Updates/index.ts +1 -1
- package/components/views/DesktopView.tsx +9 -11
- package/components/views/MobileView.tsx +4 -10
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +146 -61
- package/content/whats-new.json +48 -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/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- 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/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/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/session-url.ts +11 -5
- package/package.json +1 -1
- 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-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/check +2 -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/components/QuickSwitcher.tsx +0 -286
|
@@ -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/session-url.ts
CHANGED
|
@@ -193,8 +193,11 @@ export function locateSession(
|
|
|
193
193
|
|
|
194
194
|
export interface SessionUrlEnv {
|
|
195
195
|
href(): string;
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
// The session the address names goes along, for the entry's own record.
|
|
197
|
+
push(href: string, sessionId: string | null): void;
|
|
198
|
+
replace(href: string, sessionId: string | null): void;
|
|
199
|
+
/** The address already says it: the entry still learns which session. */
|
|
200
|
+
settle?(sessionId: string | null): void;
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
export interface SessionUrlDeps {
|
|
@@ -301,9 +304,12 @@ export class SessionUrlSync {
|
|
|
301
304
|
id ? { id, name: this.deps.nameOf(id) ?? undefined } : null
|
|
302
305
|
);
|
|
303
306
|
const url = new URL(href, BASE);
|
|
304
|
-
if (next === url.pathname + url.search + url.hash)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
if (next === url.pathname + url.search + url.hash) {
|
|
308
|
+
this.env.settle?.(id);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (mode === "push") this.env.push(next, id);
|
|
312
|
+
else this.env.replace(next, id);
|
|
307
313
|
}
|
|
308
314
|
}
|
|
309
315
|
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/check
CHANGED
|
@@ -37,7 +37,7 @@ if has static; then
|
|
|
37
37
|
out=$(mktemp -d); trap 'rm -rf "$out"' EXIT
|
|
38
38
|
pids=()
|
|
39
39
|
npx tsc --noEmit >"$out/typecheck" 2>&1 & pids+=($!)
|
|
40
|
-
npx eslint . --max-warnings=
|
|
40
|
+
npx eslint . --max-warnings=28 --concurrency=auto >"$out/lint" 2>&1 & pids+=($!)
|
|
41
41
|
npx prettier --check . --log-level warn >"$out/format" 2>&1 & pids+=($!)
|
|
42
42
|
failed=()
|
|
43
43
|
names=(typecheck lint format)
|
|
@@ -51,7 +51,7 @@ if has static; then
|
|
|
51
51
|
fi
|
|
52
52
|
else
|
|
53
53
|
step "typecheck"; npx tsc --noEmit
|
|
54
|
-
step "lint"; npx eslint . --max-warnings=
|
|
54
|
+
step "lint"; npx eslint . --max-warnings=28 --concurrency=auto
|
|
55
55
|
step "format"; npx prettier --check . --log-level warn
|
|
56
56
|
fi
|
|
57
57
|
fi
|
package/stores/chatJump.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { hrefWithSession } from "@/lib/session-url";
|
|
2
|
+
import { go } from "@/components/Chat/session-link";
|
|
2
3
|
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
3
4
|
|
|
4
5
|
// Opens a chat by its address, then takes it to one message (bookmarks and
|
|
5
6
|
// search both end here). The chat takes the jump once it is on screen.
|
|
6
7
|
export function openChatAt(sessionId: string, itemId: string): void {
|
|
7
|
-
|
|
8
|
-
null,
|
|
9
|
-
"",
|
|
10
|
-
hrefWithSession(location.href, { id: sessionId })
|
|
11
|
-
);
|
|
12
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
8
|
+
go(hrefWithSession(location.href, { id: sessionId }), sessionId);
|
|
13
9
|
bookmarksUiActions.jump(sessionId, itemId);
|
|
14
10
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
import type { ChatView } from "@/lib/chat-views/types";
|
|
3
|
+
import type { Position } from "@/lib/nav/position";
|
|
4
|
+
|
|
5
|
+
// A place asked for and not taken this soon is stale.
|
|
6
|
+
const TARGET_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
// When each chat was last opened, apart from `views` so that a reader moving
|
|
9
|
+
// through a chat doesn't redraw everything that lists chats by recency.
|
|
10
|
+
export const chatOpened = proxy<Record<string, number>>({});
|
|
11
|
+
|
|
12
|
+
const noteOpened = (v: ChatView) => {
|
|
13
|
+
if (chatOpened[v.sessionId] !== v.viewedAt)
|
|
14
|
+
chatOpened[v.sessionId] = v.viewedAt;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// What the page knows of where each chat was last read (the server's copy,
|
|
18
|
+
// kept current as the reader moves), a place a history entry asks a chat to
|
|
19
|
+
// open at, and the chats to open at their latest message.
|
|
20
|
+
export const chatPositions = proxy<{
|
|
21
|
+
views: Record<string, ChatView>;
|
|
22
|
+
loaded: boolean;
|
|
23
|
+
target: { sessionId: string; pos: Position; at: number } | null;
|
|
24
|
+
latest: string[];
|
|
25
|
+
}>({ views: {}, loaded: false, target: null, latest: [] });
|
|
26
|
+
|
|
27
|
+
export const chatPositionsActions = {
|
|
28
|
+
// The server's list; what this page wrote since is newer and stays.
|
|
29
|
+
hydrate: (list: ChatView[]) => {
|
|
30
|
+
for (const v of list) {
|
|
31
|
+
const mine = chatPositions.views[v.sessionId];
|
|
32
|
+
if (!mine || mine.viewedAt < v.viewedAt)
|
|
33
|
+
chatPositions.views[v.sessionId] = v;
|
|
34
|
+
noteOpened(chatPositions.views[v.sessionId]);
|
|
35
|
+
}
|
|
36
|
+
chatPositions.loaded = true;
|
|
37
|
+
},
|
|
38
|
+
// The list couldn't be had: chats open at their latest, as they used to.
|
|
39
|
+
giveUp: () => {
|
|
40
|
+
chatPositions.loaded = true;
|
|
41
|
+
},
|
|
42
|
+
record: (view: ChatView) => {
|
|
43
|
+
chatPositions.views[view.sessionId] = view;
|
|
44
|
+
noteOpened(view);
|
|
45
|
+
},
|
|
46
|
+
setTarget: (sessionId: string, pos: Position) => {
|
|
47
|
+
chatPositions.target = { sessionId, pos, at: Date.now() };
|
|
48
|
+
},
|
|
49
|
+
takeTarget: (sessionId: string): Position | null => {
|
|
50
|
+
const t = chatPositions.target;
|
|
51
|
+
if (!t || t.sessionId !== sessionId) return null;
|
|
52
|
+
chatPositions.target = null;
|
|
53
|
+
return Date.now() - t.at < TARGET_MS ? t.pos : null;
|
|
54
|
+
},
|
|
55
|
+
wantLatest: (sessionId: string) => {
|
|
56
|
+
if (!chatPositions.latest.includes(sessionId))
|
|
57
|
+
chatPositions.latest.push(sessionId);
|
|
58
|
+
},
|
|
59
|
+
takeLatest: (sessionId: string): boolean => {
|
|
60
|
+
const at = chatPositions.latest.indexOf(sessionId);
|
|
61
|
+
if (at === -1) return false;
|
|
62
|
+
chatPositions.latest.splice(at, 1);
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
import { HistoryWriter, navStateOf } from "@/lib/nav/history-writer";
|
|
3
|
+
import {
|
|
4
|
+
emptyMirror,
|
|
5
|
+
moveTo,
|
|
6
|
+
renameIn,
|
|
7
|
+
visit,
|
|
8
|
+
type Mirror,
|
|
9
|
+
} from "@/lib/nav/history-mirror";
|
|
10
|
+
import type { Position } from "@/lib/nav/position";
|
|
11
|
+
|
|
12
|
+
const KEY = "agentos-nav-history";
|
|
13
|
+
|
|
14
|
+
function load(): Mirror {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(sessionStorage.getItem(KEY) ?? "null");
|
|
17
|
+
if (raw && Array.isArray(raw.entries) && typeof raw.current === "number")
|
|
18
|
+
return raw;
|
|
19
|
+
} catch {
|
|
20
|
+
// Unavailable or corrupt: start over.
|
|
21
|
+
}
|
|
22
|
+
return emptyMirror();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noHistory = typeof window === "undefined";
|
|
26
|
+
// Made on first use in the browser.
|
|
27
|
+
let writer: HistoryWriter | null = null;
|
|
28
|
+
const w = () => (writer ??= new HistoryWriter(window.history));
|
|
29
|
+
|
|
30
|
+
// The entries this tab made, for the back button's menu and for knowing
|
|
31
|
+
// whether there is anywhere to go. Kept in sessionStorage across a reload.
|
|
32
|
+
export const navHistory = proxy<Mirror>(noHistory ? emptyMirror() : load());
|
|
33
|
+
|
|
34
|
+
function set(next: Mirror) {
|
|
35
|
+
navHistory.entries = next.entries;
|
|
36
|
+
navHistory.current = next.current;
|
|
37
|
+
try {
|
|
38
|
+
sessionStorage.setItem(KEY, JSON.stringify(next));
|
|
39
|
+
} catch {
|
|
40
|
+
// The menu is lost on reload; history itself is not.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const snapshot = (): Mirror => ({
|
|
45
|
+
entries: navHistory.entries.map((e) => ({ ...e })),
|
|
46
|
+
current: navHistory.current,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const navHistoryActions = {
|
|
50
|
+
/** A new entry for a chat switch or a bookmark. */
|
|
51
|
+
push: (href: string, sessionId: string | null, name: string) => {
|
|
52
|
+
const s = w().push(href, sessionId);
|
|
53
|
+
set(
|
|
54
|
+
visit(
|
|
55
|
+
snapshot(),
|
|
56
|
+
{ index: s.index, sessionId: sessionId ?? "", name },
|
|
57
|
+
"push"
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
/** The same entry at another address, or the first sight of this one. */
|
|
62
|
+
replace: (href: string, sessionId: string | null, name: string) => {
|
|
63
|
+
const s = w().replace(href, sessionId);
|
|
64
|
+
set(
|
|
65
|
+
visit(
|
|
66
|
+
snapshot(),
|
|
67
|
+
{ index: s.index, sessionId: sessionId ?? "", name },
|
|
68
|
+
"replace"
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
/** Where the reader is in the chat, for the entry they're on. */
|
|
73
|
+
setPosition: (sessionId: string, pos: Position) =>
|
|
74
|
+
w().setPosition(sessionId, pos),
|
|
75
|
+
flush: () => w().flush(),
|
|
76
|
+
/** Back or forward landed on an entry: where it was made, if known. */
|
|
77
|
+
popped: () => {
|
|
78
|
+
const s = w().current();
|
|
79
|
+
if (s) set(moveTo(snapshot(), s.index));
|
|
80
|
+
return s;
|
|
81
|
+
},
|
|
82
|
+
rename: (sessionId: string, name: string) =>
|
|
83
|
+
set(renameIn(snapshot(), sessionId, name)),
|
|
84
|
+
back: () => {
|
|
85
|
+
w().flush();
|
|
86
|
+
window.history.back();
|
|
87
|
+
},
|
|
88
|
+
forward: () => {
|
|
89
|
+
w().flush();
|
|
90
|
+
window.history.forward();
|
|
91
|
+
},
|
|
92
|
+
/** The menu's pick: any entry, by index. */
|
|
93
|
+
goTo: (index: number) => {
|
|
94
|
+
w().flush();
|
|
95
|
+
window.history.go(index - navHistory.current);
|
|
96
|
+
},
|
|
97
|
+
currentState: () => (noHistory ? null : navStateOf(window.history.state)),
|
|
98
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
|
|
3
|
+
// The shortcuts panel, and the first key of a sequence that is waiting for
|
|
4
|
+
// its second (shown as a chip so the reader sees it was heard).
|
|
5
|
+
export const shortcutsUi = proxy<{ open: boolean; pending: string | null }>({
|
|
6
|
+
open: false,
|
|
7
|
+
pending: null,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const shortcutsUiActions = {
|
|
11
|
+
open: () => {
|
|
12
|
+
shortcutsUi.open = true;
|
|
13
|
+
},
|
|
14
|
+
setOpen: (open: boolean) => {
|
|
15
|
+
shortcutsUi.open = open;
|
|
16
|
+
},
|
|
17
|
+
setPending: (key: string | null) => {
|
|
18
|
+
shortcutsUi.pending = key;
|
|
19
|
+
},
|
|
20
|
+
};
|