@saadnvd1/agent-os 0.3.799 → 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/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/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/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +146 -61
- package/content/whats-new.json +32 -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
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/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;
|
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
|
+
});
|