authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 01:01:13-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 01:01:13-08:00
logfe86836b43dbcb49598d8e7a728c7febb9584b3c
treef7266f24c8d482c96db5f97bd334b89b2ed53f64
parent973e62373e5b442ac9c13e0e65f771132c5e66d4
parent73fa2d66c79eb1351fbae55a81c61e1f7b88c619

Merge origin/main and resolve PR status/sidebar conflicts

Co-authored-by: codex <codex@users.noreply.github.com>

34 files changed, 2887 insertions(+), 661 deletions(-)

KEYBINDINGS.md created+100
...@@ -0,0 +1,100 @@
1# Keybindings
2
3T3 Code reads keybindings from:
4
5- `~/.t3/keybindings.json`
6
7The file must be a JSON array of rules:
8
9```json
10[
11 { "key": "mod+g", "command": "terminal.toggle" },
12 { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" }
13]
14```
15
16See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts)
17
18## Defaults
19
20```json
21[
22 { "key": "mod+j", "command": "terminal.toggle" },
23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
26 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
27 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
28 { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
29 { "key": "mod+o", "command": "editor.openFavorite" }
30]
31```
32
33For most up to date defaults, see [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
34
35## Configuration
36
37### Rule Shape
38
39Each entry supports:
40
41- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d`
42- `command` (required): action ID
43- `when` (optional): boolean expression controlling when the shortcut is active
44
45Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server.
46
47### Available Commands
48
49- `terminal.toggle`: open/close terminal drawer
50- `terminal.split`: split terminal (in focused terminal context by default)
51- `terminal.new`: create new terminal (in focused terminal context by default)
52- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
54- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)
55- `editor.openFavorite`: open current project/worktree in the last-used editor
56
57### Key Syntax
58
59Supported modifiers:
60
61- `mod` (`cmd` on macOS, `ctrl` on non-macOS)
62- `cmd` / `meta`
63- `ctrl` / `control`
64- `shift`
65- `alt` / `option`
66
67Examples:
68
69- `mod+j`
70- `mod+shift+d`
71- `ctrl+l`
72- `cmd+k`
73
74### `when` Conditions
75
76Currently available context keys:
77
78- `terminalFocus`
79- `terminalOpen`
80
81Supported operators:
82
83- `!` (not)
84- `&&` (and)
85- `||` (or)
86- parentheses: `(` `)`
87
88Examples:
89
90- `"when": "terminalFocus"`
91- `"when": "terminalOpen && !terminalFocus"`
92- `"when": "terminalFocus || terminalOpen"`
93
94Unknown condition keys evaluate to `false`.
95
96### Precedence
97
98- Rules are evaluated in array order.
99- For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins.
100- That means precedence is across commands, not only within the same command.
apps/server/src/gitManager.test.ts+14-15
...@@ -190,15 +190,15 @@ describe("GitManager", () => {...@@ -190,15 +190,15 @@ describe("GitManager", () => {
190190
191 const status = await manager.status({ cwd: repoDir });191 const status = await manager.status({ cwd: repoDir });
192 expect(status.branch).toBe("feature/status-open-pr");192 expect(status.branch).toBe("feature/status-open-pr");
193 expect(status.openPr).toEqual({193 expect(status.pr).toEqual({
194 number: 13,194 number: 13,
195 title: "Existing PR",195 title: "Existing PR",
196 url: "https://github.com/pingdotgg/codething-mvp/pull/13",196 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
197 baseBranch: "main",197 baseBranch: "main",
198 headBranch: "feature/status-open-pr",198 headBranch: "feature/status-open-pr",
199 state: "open",
199 });200 });
200 expect(status.mergedPr).toBeNull();201 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
201 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false);
202 });202 });
203203
204 it("status includes merged PR metadata when there is no open PR", async () => {204 it("status includes merged PR metadata when there is no open PR", async () => {
...@@ -211,7 +211,6 @@ describe("GitManager", () => {...@@ -211,7 +211,6 @@ describe("GitManager", () => {
211211
212 const { runner, ghCalls } = createRunnerWithFakeGh({212 const { runner, ghCalls } = createRunnerWithFakeGh({
213 prListSequence: [213 prListSequence: [
214 "[]",
215 JSON.stringify([214 JSON.stringify([
216 {215 {
217 number: 26,216 number: 26,
...@@ -219,6 +218,9 @@ describe("GitManager", () => {...@@ -219,6 +218,9 @@ describe("GitManager", () => {
219 url: "https://github.com/pingdotgg/codething-mvp/pull/26",218 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
220 baseRefName: "main",219 baseRefName: "main",
221 headRefName: "feature/status-merged-pr",220 headRefName: "feature/status-merged-pr",
221 state: "CLOSED",
222 mergedAt: "2026-02-14T10:00:00Z",
223 updatedAt: "2026-02-14T10:00:00Z",
222 },224 },
223 ]),225 ]),
224 ],226 ],
...@@ -231,18 +233,18 @@ describe("GitManager", () => {...@@ -231,18 +233,18 @@ describe("GitManager", () => {
231233
232 const status = await manager.status({ cwd: repoDir });234 const status = await manager.status({ cwd: repoDir });
233 expect(status.branch).toBe("feature/status-merged-pr");235 expect(status.branch).toBe("feature/status-merged-pr");
234 expect(status.openPr).toBeNull();236 expect(status.pr).toEqual({
235 expect(status.mergedPr).toEqual({
236 number: 26,237 number: 26,
237 title: "Merged PR",238 title: "Merged PR",
238 url: "https://github.com/pingdotgg/codething-mvp/pull/26",239 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
239 baseBranch: "main",240 baseBranch: "main",
240 headBranch: "feature/status-merged-pr",241 headBranch: "feature/status-merged-pr",
242 state: "merged",
241 });243 });
242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);244 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
243 });245 });
244246
245 it("status skips open PR lookup for branches without upstream", async () => {247 it("status checks all PR states for branches without upstream", async () => {
246 const repoDir = makeTempDir("t3code-git-manager-");248 const repoDir = makeTempDir("t3code-git-manager-");
247 await initRepo(repoDir);249 await initRepo(repoDir);
248 await runGit(repoDir, ["checkout", "-b", "feature/local-only"]);250 await runGit(repoDir, ["checkout", "-b", "feature/local-only"]);
...@@ -258,13 +260,11 @@ describe("GitManager", () => {...@@ -258,13 +260,11 @@ describe("GitManager", () => {
258260
259 const status = await manager.status({ cwd: repoDir });261 const status = await manager.status({ cwd: repoDir });
260 expect(status.branch).toBe("feature/local-only");262 expect(status.branch).toBe("feature/local-only");
261 expect(status.openPr).toBeNull();263 expect(status.pr).toBeNull();
262 expect(status.mergedPr).toBeNull();264 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
263 expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false);
264 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
265 });265 });
266266
267 it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => {267 it("status is resilient to gh lookup failures and returns pr null", async () => {
268 const repoDir = makeTempDir("t3code-git-manager-");268 const repoDir = makeTempDir("t3code-git-manager-");
269 await initRepo(repoDir);269 await initRepo(repoDir);
270 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);270 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
...@@ -282,8 +282,7 @@ describe("GitManager", () => {...@@ -282,8 +282,7 @@ describe("GitManager", () => {
282282
283 const status = await manager.status({ cwd: repoDir });283 const status = await manager.status({ cwd: repoDir });
284 expect(status.branch).toBe("feature/status-no-gh");284 expect(status.branch).toBe("feature/status-no-gh");
285 expect(status.openPr).toBeNull();285 expect(status.pr).toBeNull();
286 expect(status.mergedPr).toBeNull();
287 });286 });
288287
289 it("creates a commit when working tree is dirty", async () => {288 it("creates a commit when working tree is dirty", async () => {
apps/server/src/gitManager.ts+2-3
...@@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {...@@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
67 typeof title !== "string" ||67 typeof title !== "string" ||
68 typeof url !== "string" ||68 typeof url !== "string" ||
69 typeof baseRefName !== "string" ||69 typeof baseRefName !== "string" ||
70 typeof headRefName !== "string" ||70 typeof headRefName !== "string"
71 typeof state !== "string"
72 ) {71 ) {
73 continue;72 continue;
74 }73 }
...@@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {...@@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
76 let normalizedState: GitStatusPrState;75 let normalizedState: GitStatusPrState;
77 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {76 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
78 normalizedState = "merged";77 normalizedState = "merged";
79 } else if (state === "OPEN") {78 } else if (state === "OPEN" || state === undefined || state === null) {
80 normalizedState = "open";79 normalizedState = "open";
81 } else if (state === "CLOSED") {80 } else if (state === "CLOSED") {
82 normalizedState = "closed";81 normalizedState = "closed";
apps/server/src/keybindings.test.ts created+69
...@@ -0,0 +1,69 @@
1import { assert, describe, it } from "vitest";
2
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
4
5describe("server keybindings", () => {
6 it("parses shortcuts including plus key", () => {
7 assert.deepEqual(parseKeybindingShortcut("mod+j"), {
8 key: "j",
9 metaKey: false,
10 ctrlKey: false,
11 shiftKey: false,
12 altKey: false,
13 modKey: true,
14 });
15 assert.deepEqual(parseKeybindingShortcut("mod++"), {
16 key: "+",
17 metaKey: false,
18 ctrlKey: false,
19 shiftKey: false,
20 altKey: false,
21 modKey: true,
22 });
23 });
24
25 it("compiles valid rule with parsed when AST", () => {
26 const compiled = compileResolvedKeybindingRule({
27 key: "mod+d",
28 command: "terminal.split",
29 when: "terminalOpen && !terminalFocus",
30 });
31
32 assert.deepEqual(compiled, {
33 command: "terminal.split",
34 shortcut: {
35 key: "d",
36 metaKey: false,
37 ctrlKey: false,
38 shiftKey: false,
39 altKey: false,
40 modKey: true,
41 },
42 whenAst: {
43 type: "and",
44 left: { type: "identifier", name: "terminalOpen" },
45 right: {
46 type: "not",
47 node: { type: "identifier", name: "terminalFocus" },
48 },
49 },
50 });
51 });
52
53 it("rejects invalid rules", () => {
54 assert.isNull(
55 compileResolvedKeybindingRule({
56 key: "mod+shift+d+o",
57 command: "terminal.new",
58 }),
59 );
60
61 assert.isNull(
62 compileResolvedKeybindingRule({
63 key: "mod+d",
64 command: "terminal.split",
65 when: "terminalFocus && (",
66 }),
67 );
68 });
69});
apps/server/src/keybindings.ts created+355
...@@ -0,0 +1,355 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type {
6 KeybindingRule,
7 KeybindingsConfig,
8 KeybindingShortcut,
9 KeybindingWhenNode,
10 ResolvedKeybindingRule,
11 ResolvedKeybindingsConfig,
12} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
14
15interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;
17}
18
19type WhenToken =
20 | { type: "identifier"; value: string }
21 | { type: "not" }
22 | { type: "and" }
23 | { type: "or" }
24 | { type: "lparen" }
25 | { type: "rparen" };
26
27export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
28 { key: "mod+j", command: "terminal.toggle" },
29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
30 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
31 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
32 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
33 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
34 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
35 { key: "mod+o", command: "editor.openFavorite" },
36];
37
38function normalizeKeyToken(token: string): string {
39 if (token === "space") return " ";
40 if (token === "esc") return "escape";
41 return token;
42}
43
44export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
45 const rawTokens = value
46 .toLowerCase()
47 .split("+")
48 .map((token) => token.trim());
49 const tokens = [...rawTokens];
50 let trailingEmptyCount = 0;
51 while (tokens[tokens.length - 1] === "") {
52 trailingEmptyCount += 1;
53 tokens.pop();
54 }
55 if (trailingEmptyCount > 0) {
56 tokens.push("+");
57 }
58 if (tokens.some((token) => token.length === 0)) {
59 return null;
60 }
61 if (tokens.length === 0) return null;
62
63 let key: string | null = null;
64 let metaKey = false;
65 let ctrlKey = false;
66 let shiftKey = false;
67 let altKey = false;
68 let modKey = false;
69
70 for (const token of tokens) {
71 switch (token) {
72 case "cmd":
73 case "meta":
74 metaKey = true;
75 break;
76 case "ctrl":
77 case "control":
78 ctrlKey = true;
79 break;
80 case "shift":
81 shiftKey = true;
82 break;
83 case "alt":
84 case "option":
85 altKey = true;
86 break;
87 case "mod":
88 modKey = true;
89 break;
90 default: {
91 if (key !== null) return null;
92 key = normalizeKeyToken(token);
93 }
94 }
95 }
96
97 if (key === null) return null;
98 return {
99 key,
100 metaKey,
101 ctrlKey,
102 shiftKey,
103 altKey,
104 modKey,
105 };
106}
107
108function tokenizeWhenExpression(expression: string): WhenToken[] | null {
109 const tokens: WhenToken[] = [];
110 let index = 0;
111
112 while (index < expression.length) {
113 const current = expression[index];
114 if (!current) break;
115
116 if (/\s/.test(current)) {
117 index += 1;
118 continue;
119 }
120 if (expression.startsWith("&&", index)) {
121 tokens.push({ type: "and" });
122 index += 2;
123 continue;
124 }
125 if (expression.startsWith("||", index)) {
126 tokens.push({ type: "or" });
127 index += 2;
128 continue;
129 }
130 if (current === "!") {
131 tokens.push({ type: "not" });
132 index += 1;
133 continue;
134 }
135 if (current === "(") {
136 tokens.push({ type: "lparen" });
137 index += 1;
138 continue;
139 }
140 if (current === ")") {
141 tokens.push({ type: "rparen" });
142 index += 1;
143 continue;
144 }
145
146 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
147 if (!identifier) {
148 return null;
149 }
150 tokens.push({ type: "identifier", value: identifier[0] });
151 index += identifier[0].length;
152 }
153
154 return tokens;
155}
156
157export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
158 const tokens = tokenizeWhenExpression(expression);
159 if (!tokens || tokens.length === 0) return null;
160 let index = 0;
161
162 const parsePrimary = (): KeybindingWhenNode | null => {
163 const token = tokens[index];
164 if (!token) return null;
165
166 if (token.type === "identifier") {
167 index += 1;
168 return { type: "identifier", name: token.value };
169 }
170
171 if (token.type === "lparen") {
172 index += 1;
173 const expressionNode = parseOr();
174 const closeToken = tokens[index];
175 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
176 return null;
177 }
178 index += 1;
179 return expressionNode;
180 }
181
182 return null;
183 };
184
185 const parseUnary = (): KeybindingWhenNode | null => {
186 const token = tokens[index];
187 if (token?.type === "not") {
188 index += 1;
189 const node = parseUnary();
190 if (!node) return null;
191 return { type: "not", node };
192 }
193 return parsePrimary();
194 };
195
196 const parseAnd = (): KeybindingWhenNode | null => {
197 let left = parseUnary();
198 if (!left) return null;
199
200 while (tokens[index]?.type === "and") {
201 index += 1;
202 const right = parseUnary();
203 if (!right) return null;
204 left = { type: "and", left, right };
205 }
206
207 return left;
208 };
209
210 const parseOr = (): KeybindingWhenNode | null => {
211 let left = parseAnd();
212 if (!left) return null;
213
214 while (tokens[index]?.type === "or") {
215 index += 1;
216 const right = parseAnd();
217 if (!right) return null;
218 left = { type: "or", left, right };
219 }
220
221 return left;
222 };
223
224 const ast = parseOr();
225 if (!ast || index !== tokens.length) return null;
226 return ast;
227}
228
229export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
230 const key = rule.key.trim();
231 if (key.length === 0) return null;
232 const shortcut = parseKeybindingShortcut(key);
233 if (!shortcut) return null;
234
235 const when = rule.when?.trim();
236 if (when && when.length > 0) {
237 const whenAst = parseKeybindingWhenExpression(when);
238 if (!whenAst) return null;
239 return {
240 command: rule.command,
241 shortcut,
242 whenAst,
243 };
244 }
245
246 return {
247 command: rule.command,
248 shortcut,
249 };
250}
251
252export function compileResolvedKeybindingsConfig(
253 config: KeybindingsConfig,
254): ResolvedKeybindingsConfig {
255 const compiled: ResolvedKeybindingsConfig = [];
256 for (const rule of config) {
257 const resolved = compileResolvedKeybindingRule(rule);
258 if (!resolved) continue;
259 compiled.push(resolved);
260 }
261 return compiled;
262}
263
264function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
265 const resolved: ResolvedKeybindingsConfig = [];
266 for (const rule of DEFAULT_KEYBINDINGS) {
267 const compiled = compileResolvedKeybindingRule(rule);
268 if (!compiled) {
269 throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);
270 }
271 resolved.push(compiled);
272 }
273 return resolved;
274}
275
276const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
277
278function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
279 if (custom.length === 0) {
280 return [...DEFAULT_RESOLVED_KEYBINDINGS];
281 }
282
283 const overriddenCommands = new Set(custom.map((binding) => binding.command));
284 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
285 (binding) => !overriddenCommands.has(binding.command),
286 );
287 const merged = [...retainedDefaults, ...custom];
288
289 if (merged.length <= 256) {
290 return merged;
291 }
292
293 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
294 return merged.slice(-256);
295}
296
297export function loadResolvedKeybindingsConfig(
298 logger: KeybindingsLogger,
299): ResolvedKeybindingsConfig {
300 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
301 try {
302 const raw = fs.readFileSync(configPath, "utf8");
303 const parsed = JSON.parse(raw) as unknown;
304 if (!Array.isArray(parsed)) {
305 logger.warn("ignoring keybindings config with unsupported format; expected array", {
306 path: configPath,
307 });
308 return [...DEFAULT_RESOLVED_KEYBINDINGS];
309 }
310
311 const sanitized: ResolvedKeybindingsConfig = [];
312 let invalidEntries = 0;
313 for (const entry of parsed) {
314 const result = keybindingRuleSchema.safeParse(entry);
315 if (result.success) {
316 const compiled = compileResolvedKeybindingRule(result.data);
317 if (!compiled) {
318 invalidEntries += 1;
319 continue;
320 }
321 sanitized.push(compiled);
322 continue;
323 }
324 invalidEntries += 1;
325 }
326 if (invalidEntries > 0) {
327 logger.warn("ignoring invalid keybinding entries", {
328 path: configPath,
329 invalidEntries,
330 totalEntries: parsed.length,
331 });
332 }
333 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
334 const mergedBeforeCapLength =
335 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
336 .length + sanitized.length;
337 const merged = mergeWithDefaultKeybindings(sanitized);
338 if (mergedBeforeCapLength > 256) {
339 logger.warn("truncating merged keybindings config to max entries", {
340 path: configPath,
341 maxEntries: 256,
342 });
343 }
344 return merged;
345 } catch (error) {
346 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
347 return [...DEFAULT_RESOLVED_KEYBINDINGS];
348 }
349 logger.warn("ignoring malformed keybindings config", {
350 path: configPath,
351 error: error instanceof Error ? error.message : String(error),
352 });
353 }
354 return [...DEFAULT_RESOLVED_KEYBINDINGS];
355}
apps/server/src/wsServer.test.ts+248-1
...@@ -11,10 +11,13 @@ import {...@@ -11,10 +11,13 @@ import {
11 DEFAULT_TERMINAL_ID,11 DEFAULT_TERMINAL_ID,
12 WS_CHANNELS,12 WS_CHANNELS,
13 WS_METHODS,13 WS_METHODS,
14 type KeybindingsConfig,
15 type ResolvedKeybindingsConfig,
14 type WsPush,16 type WsPush,
15 type WsResponse,17 type WsResponse,
16} from "@t3tools/contracts";18} from "@t3tools/contracts";
17import { ProjectRegistry } from "./projectRegistry";19import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
18import type {21import type {
19 TerminalClearInput,22 TerminalClearInput,
20 TerminalCloseInput,23 TerminalCloseInput,
...@@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro...@@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
192 }195 }
193}196}
194197
198function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
199 const resolved: ResolvedKeybindingsConfig = [];
200 for (const binding of bindings) {
201 const compiled = compileResolvedKeybindingRule(binding);
202 if (!compiled) {
203 throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);
204 }
205 resolved.push(compiled);
206 }
207 return resolved;
208}
209
210const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
211
212function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig {
213 if (custom.length === 0) {
214 return DEFAULT_RESOLVED_KEYBINDINGS;
215 }
216
217 const overriddenCommands = new Set(custom.map((binding) => binding.command));
218 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
219 (binding) => !overriddenCommands.has(binding.command),
220 );
221 return compileKeybindings([...retainedDefaults, ...custom].slice(-256));
222}
223
195describe("WebSocket Server", () => {224describe("WebSocket Server", () => {
196 let server: ReturnType<typeof createServer> | null = null;225 let server: ReturnType<typeof createServer> | null = null;
197 const connections: WebSocket[] = [];226 const connections: WebSocket[] = [];
...@@ -307,7 +336,225 @@ describe("WebSocket Server", () => {...@@ -307,7 +336,225 @@ describe("WebSocket Server", () => {
307336
308 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);337 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
309 expect(response.error).toBeUndefined();338 expect(response.error).toBeUndefined();
310 expect(response.result).toEqual({ cwd: "/my/workspace" });339 expect(response.result).toEqual({
340 cwd: "/my/workspace",
341 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
342 });
343 });
344
345 it("reads keybindings from ~/.t3/keybindings.json", async () => {
346 const fakeHome = makeTempDir("t3code-home-");
347 const configDir = path.join(fakeHome, ".t3");
348 fs.mkdirSync(configDir, { recursive: true });
349 fs.writeFileSync(
350 path.join(configDir, "keybindings.json"),
351 JSON.stringify([
352 { key: "cmd+j", command: "terminal.toggle" },
353 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
354 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
355 ]),
356 "utf8",
357 );
358 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
359
360 server = createTestServer({ cwd: "/my/workspace" });
361 await server.start();
362 const addr = server.httpServer.address();
363 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
364
365 const ws = await connectWs(port);
366 connections.push(ws);
367
368 await waitForMessage(ws);
369
370 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
371 expect(response.error).toBeUndefined();
372 expect(response.result).toEqual({
373 cwd: "/my/workspace",
374 keybindings: mergeWithDefaultsForTest([
375 { key: "cmd+j", command: "terminal.toggle" },
376 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
377 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
378 ]),
379 });
380 });
381
382 it("warns and ignores invalid keybinding entries", async () => {
383 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
384 const fakeHome = makeTempDir("t3code-home-invalid-entry-");
385 const configDir = path.join(fakeHome, ".t3");
386 fs.mkdirSync(configDir, { recursive: true });
387 fs.writeFileSync(
388 path.join(configDir, "keybindings.json"),
389 JSON.stringify([
390 { key: "mod+j", command: "terminal.toggle" },
391 { key: "mod+z", command: "invalid.command", when: "terminalFocus" },
392 ]),
393 "utf8",
394 );
395 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
396
397 server = createTestServer({ cwd: "/my/workspace" });
398 await server.start();
399 const addr = server.httpServer.address();
400 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
401
402 const ws = await connectWs(port);
403 connections.push(ws);
404
405 await waitForMessage(ws);
406
407 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
408 expect(response.error).toBeUndefined();
409 expect(response.result).toEqual({
410 cwd: "/my/workspace",
411 keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]),
412 });
413 expect(
414 warnSpy.mock.calls.some(([message]) =>
415 String(message).includes("ignoring invalid keybinding entries"),
416 ),
417 ).toBe(true);
418 });
419
420 it("warns and ignores keybindings with malformed when expressions", async () => {
421 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
422 const fakeHome = makeTempDir("t3code-home-invalid-when-");
423 const configDir = path.join(fakeHome, ".t3");
424 fs.mkdirSync(configDir, { recursive: true });
425 fs.writeFileSync(
426 path.join(configDir, "keybindings.json"),
427 JSON.stringify([{ key: "mod+j", command: "terminal.toggle", when: "terminalFocus && (" }]),
428 "utf8",
429 );
430 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
431
432 server = createTestServer({ cwd: "/my/workspace" });
433 await server.start();
434 const addr = server.httpServer.address();
435 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
436
437 const ws = await connectWs(port);
438 connections.push(ws);
439
440 await waitForMessage(ws);
441
442 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
443 expect(response.error).toBeUndefined();
444 expect(response.result).toEqual({
445 cwd: "/my/workspace",
446 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
447 });
448 expect(
449 warnSpy.mock.calls.some(([message]) =>
450 String(message).includes("ignoring invalid keybinding entries"),
451 ),
452 ).toBe(true);
453 });
454
455 it("reads keybindings once at startup and caches the resolved config", async () => {
456 const fakeHome = makeTempDir("t3code-home-cached-config-");
457 const configDir = path.join(fakeHome, ".t3");
458 fs.mkdirSync(configDir, { recursive: true });
459 const keybindingsPath = path.join(configDir, "keybindings.json");
460 fs.writeFileSync(
461 keybindingsPath,
462 JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]),
463 "utf8",
464 );
465 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
466
467 server = createTestServer({ cwd: "/my/workspace" });
468 await server.start();
469 const addr = server.httpServer.address();
470 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
471
472 const ws = await connectWs(port);
473 connections.push(ws);
474 await waitForMessage(ws);
475
476 const firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
477 expect(firstResponse.error).toBeUndefined();
478 expect(firstResponse.result).toEqual({
479 cwd: "/my/workspace",
480 keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]),
481 });
482
483 fs.writeFileSync(
484 keybindingsPath,
485 JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]),
486 "utf8",
487 );
488
489 const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
490 expect(secondResponse.error).toBeUndefined();
491 expect(secondResponse.result).toEqual(firstResponse.result);
492 });
493
494 it("warns and ignores unsupported keybindings config format", async () => {
495 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
496 const fakeHome = makeTempDir("t3code-home-unsupported-format-");
497 const configDir = path.join(fakeHome, ".t3");
498 fs.mkdirSync(configDir, { recursive: true });
499 fs.writeFileSync(
500 path.join(configDir, "keybindings.json"),
501 JSON.stringify({ "terminal.toggle": "mod+j" }),
502 "utf8",
503 );
504 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
505
506 server = createTestServer({ cwd: "/my/workspace" });
507 await server.start();
508 const addr = server.httpServer.address();
509 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
510
511 const ws = await connectWs(port);
512 connections.push(ws);
513
514 await waitForMessage(ws);
515
516 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
517 expect(response.error).toBeUndefined();
518 expect(response.result).toEqual({
519 cwd: "/my/workspace",
520 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
521 });
522 expect(
523 warnSpy.mock.calls.some(([message]) =>
524 String(message).includes("unsupported format; expected array"),
525 ),
526 ).toBe(true);
527 });
528
529 it("warns and ignores malformed keybindings config files", async () => {
530 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
531 const fakeHome = makeTempDir("t3code-home-invalid-");
532 const configDir = path.join(fakeHome, ".t3");
533 fs.mkdirSync(configDir, { recursive: true });
534 fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8");
535 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
536
537 server = createTestServer({ cwd: "/my/workspace" });
538 await server.start();
539 const addr = server.httpServer.address();
540 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
541
542 const ws = await connectWs(port);
543 connections.push(ws);
544
545 await waitForMessage(ws);
546
547 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
548 expect(response.error).toBeUndefined();
549 expect(response.result).toEqual({
550 cwd: "/my/workspace",
551 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
552 });
553 expect(
554 warnSpy.mock.calls.some(([message]) =>
555 String(message).includes("ignoring malformed keybindings config"),
556 ),
557 ).toBe(true);
311 });558 });
312559
313 it("returns error for unknown methods", async () => {560 it("returns error for unknown methods", async () => {
apps/server/src/wsServer.ts+6-1
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 removeGitWorktree,31 removeGitWorktree,
32} from "./git";32} from "./git";
33import { TerminalManager } from "./terminalManager";33import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig } from "./keybindings";
3435
35const MIME_TYPES: Record<string, string> = {36const MIME_TYPES: Record<string, string> = {
36 ".html": "text/html; charset=utf-8",37 ".html": "text/html; charset=utf-8",
...@@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) {...@@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) {
90 const logger = createLogger("ws");91 const logger = createLogger("ws");
91 const logWebSocketEvents =92 const logWebSocketEvents =
92 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);93 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
94 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
9395
94 function logOutgoingPush(push: WsPush, recipients: number) {96 function logOutgoingPush(push: WsPush, recipients: number) {
95 if (!logWebSocketEvents) return;97 if (!logWebSocketEvents) return;
...@@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) {...@@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) {
410 return undefined;412 return undefined;
411413
412 case WS_METHODS.serverGetConfig:414 case WS_METHODS.serverGetConfig:
413 return { cwd };415 return {
416 cwd,
417 keybindings: keybindingsConfig,
418 };
414419
415 default:420 default:
416 throw new Error(`Unknown method: ${request.method}`);421 throw new Error(`Unknown method: ${request.method}`);
apps/web/src/components/BranchToolbar.tsx+11-1
...@@ -25,9 +25,15 @@ interface BranchToolbarProps {...@@ -25,9 +25,15 @@ interface BranchToolbarProps {
25 envMode: "local" | "worktree";25 envMode: "local" | "worktree";
26 onEnvModeChange: (mode: "local" | "worktree") => void;26 onEnvModeChange: (mode: "local" | "worktree") => void;
27 envLocked: boolean;27 envLocked: boolean;
28 onComposerFocusRequest?: () => void;
28}29}
2930
30export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {31export default function BranchToolbar({
32 envMode,
33 onEnvModeChange,
34 envLocked,
35 onComposerFocusRequest,
36}: BranchToolbarProps) {
31 const { state, dispatch } = useStore();37 const { state, dispatch } = useStore();
32 const api = useNativeApi();38 const api = useNativeApi();
33 const queryClient = useQueryClient();39 const queryClient = useQueryClient();
...@@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
123 setThreadError(null);129 setThreadError(null);
124 setThreadBranch(branch.name, null);130 setThreadBranch(branch.name, null);
125 setIsBranchMenuOpen(false);131 setIsBranchMenuOpen(false);
132 onComposerFocusRequest?.();
126 return;133 return;
127 }134 }
128135
...@@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
135 // Secondary worktree → point the thread at that worktree path.142 // Secondary worktree → point the thread at that worktree path.
136 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);143 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
137 setIsBranchMenuOpen(false);144 setIsBranchMenuOpen(false);
145 onComposerFocusRequest?.();
138 return;146 return;
139 }147 }
140148
...@@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
143 setThreadError(null);151 setThreadError(null);
144 setThreadBranch(branch.name, activeWorktreePath);152 setThreadBranch(branch.name, activeWorktreePath);
145 setIsBranchMenuOpen(false);153 setIsBranchMenuOpen(false);
154 onComposerFocusRequest?.();
146 },155 },
147 });156 });
148 };157 };
...@@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
156 setThreadBranch(name, activeWorktreePath);165 setThreadBranch(name, activeWorktreePath);
157 setBranchQuery("");166 setBranchQuery("");
158 setIsBranchMenuOpen(false);167 setIsBranchMenuOpen(false);
168 onComposerFocusRequest?.();
159 },169 },
160 });170 });
161 };171 };
apps/web/src/components/ChatMarkdown.tsx+4-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { memo } from "react";
1import type { Components } from "react-markdown";2import type { Components } from "react-markdown";
2import ReactMarkdown from "react-markdown";3import ReactMarkdown from "react-markdown";
3import rehypeHighlight from "rehype-highlight";4import rehypeHighlight from "rehype-highlight";
...@@ -13,7 +14,7 @@ const markdownComponents: Components = {...@@ -13,7 +14,7 @@ const markdownComponents: Components = {
13 },14 },
14};15};
1516
16export default function ChatMarkdown({ text }: ChatMarkdownProps) {17function ChatMarkdown({ text }: ChatMarkdownProps) {
17 return (18 return (
18 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">19 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">
19 <ReactMarkdown20 <ReactMarkdown
...@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {...@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {
26 </div>27 </div>
27 );28 );
28}29}
30
31export default memo(ChatMarkdown);
apps/web/src/components/ChatView.tsx+585-349
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1import {1import {
2 EDITORS,
3 type EditorId,
4 ModelSlug,
5 type NativeApi,
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,6 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,8 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5 type ProviderSendTurnAttachmentInput,9 type ResolvedKeybindingsConfig,
6 type ProviderApprovalDecision,10 type ProviderApprovalDecision,
7 ModelSlug,11 type ProviderSendTurnAttachmentInput,
8} from "@t3tools/contracts";12} from "@t3tools/contracts";
9import {13import {
10 type ClipboardEvent,14 type ClipboardEvent,
...@@ -12,6 +16,8 @@ import {...@@ -12,6 +16,8 @@ import {
12 type FormEvent,16 type FormEvent,
13 Fragment,17 Fragment,
14 type KeyboardEvent,18 type KeyboardEvent,
19 memo,
20 type RefObject,
15 useCallback,21 useCallback,
16 useEffect,22 useEffect,
17 useLayoutEffect,23 useLayoutEffect,
...@@ -20,12 +26,9 @@ import {...@@ -20,12 +26,9 @@ import {
20 useState,26 useState,
21} from "react";27} from "react";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";28import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23import {29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
24 gitBranchesQueryOptions,30import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
25 gitCreateWorktreeMutationOptions,
26} from "~/lib/gitReactQuery";
2731
28import { EDITORS, type EditorId } from "@t3tools/contracts";
29import { isElectron } from "../env";32import { isElectron } from "../env";
30import { buildBootstrapInput } from "../historyBootstrap";33import { buildBootstrapInput } from "../historyBootstrap";
31import {34import {
...@@ -40,6 +43,7 @@ import {...@@ -40,6 +43,7 @@ import {
40 derivePendingApprovals,43 derivePendingApprovals,
41 derivePhase,44 derivePhase,
42 deriveTimelineEntries,45 deriveTimelineEntries,
46 type PendingApproval,
43 deriveWorkLogEntries,47 deriveWorkLogEntries,
44 formatDuration,48 formatDuration,
45 formatElapsed,49 formatElapsed,
...@@ -50,7 +54,14 @@ import { useStore } from "../store";...@@ -50,7 +54,14 @@ import { useStore } from "../store";
50import type { ChatImageAttachment } from "../types";54import type { ChatImageAttachment } from "../types";
51import BranchToolbar from "./BranchToolbar";55import BranchToolbar from "./BranchToolbar";
52import GitActionsControl from "./GitActionsControl";56import GitActionsControl from "./GitActionsControl";
53import { isTerminalToggleShortcut } from "../terminal-shortcuts";57import {
58 isOpenFavoriteEditorShortcut,
59 isTerminalCloseShortcut,
60 isTerminalNewShortcut,
61 isTerminalSplitShortcut,
62 isTerminalToggleShortcut,
63 shortcutLabelForCommand,
64} from "../keybindings";
54import ChatMarkdown from "./ChatMarkdown";65import ChatMarkdown from "./ChatMarkdown";
55import ThreadTerminalDrawer from "./ThreadTerminalDrawer";66import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
56import { useNativeApi } from "../hooks/useNativeApi";67import { useNativeApi } from "../hooks/useNativeApi";
...@@ -70,7 +81,7 @@ import { Separator } from "./ui/separator";...@@ -70,7 +81,7 @@ import { Separator } from "./ui/separator";
70import { Group, GroupSeparator } from "./ui/group";81import { Group, GroupSeparator } from "./ui/group";
71import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";82import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
72import { CursorIcon, Icon } from "./Icons";83import { CursorIcon, Icon } from "./Icons";
73import { cn } from "~/lib/utils";84import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
74import { Badge } from "./ui/badge";85import { Badge } from "./ui/badge";
7586
76function formatMessageMeta(createdAt: string, duration: string | null): string {87function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;...@@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
83const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;94const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
84const IMAGE_ONLY_BOOTSTRAP_PROMPT =95const IMAGE_ONLY_BOOTSTRAP_PROMPT =
85 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";96 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
97const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
8698
87function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {99function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
88 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";100 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
...@@ -130,7 +142,9 @@ export default function ChatView() {...@@ -130,7 +142,9 @@ export default function ChatView() {
130 const { state, dispatch } = useStore();142 const { state, dispatch } = useStore();
131 const api = useNativeApi();143 const api = useNativeApi();
132 const queryClient = useQueryClient();144 const queryClient = useQueryClient();
133 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ api, queryClient }));145 const createWorktreeMutation = useMutation(
146 gitCreateWorktreeMutationOptions({ api, queryClient }),
147 );
134 const [prompt, setPrompt] = useState("");148 const [prompt, setPrompt] = useState("");
135 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);149 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
136 const [isDragOverComposer, setIsDragOverComposer] = useState(false);150 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
...@@ -154,6 +168,7 @@ export default function ChatView() {...@@ -154,6 +168,7 @@ export default function ChatView() {
154168
155 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);169 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
156 const activeThreadId = activeThread?.id ?? null;170 const activeThreadId = activeThread?.id ?? null;
171 const activeSessionId = activeThread?.session?.sessionId;
157 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);172 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
158 const selectedModel = resolveModelSlug(173 const selectedModel = resolveModelSlug(
159 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,174 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
...@@ -254,14 +269,32 @@ export default function ChatView() {...@@ -254,14 +269,32 @@ export default function ChatView() {
254 } as const);269 } as const);
255 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;270 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
256 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));271 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
272 const keybindingsQuery = useQuery({
273 ...serverConfigQueryOptions(api),
274 select: (config) => config.keybindings,
275 });
276 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
257 // Default true while loading to avoid toolbar flicker.277 // Default true while loading to avoid toolbar flicker.
258 const isGitRepo = branchesQuery.data?.isRepo ?? true;278 const isGitRepo = branchesQuery.data?.isRepo ?? true;
279 const splitTerminalShortcutLabel = useMemo(
280 () => shortcutLabelForCommand(keybindings, "terminal.split"),
281 [keybindings],
282 );
283 const newTerminalShortcutLabel = useMemo(
284 () => shortcutLabelForCommand(keybindings, "terminal.new"),
285 [keybindings],
286 );
287 const closeTerminalShortcutLabel = useMemo(
288 () => shortcutLabelForCommand(keybindings, "terminal.close"),
289 [keybindings],
290 );
259291
260 const envLocked = Boolean(292 const envLocked = Boolean(
261 activeThread &&293 activeThread &&
262 (activeThread.messages.length > 0 ||294 (activeThread.messages.length > 0 ||
263 (activeThread.session !== null && activeThread.session.status !== "closed")),295 (activeThread.session !== null && activeThread.session.status !== "closed")),
264 );296 );
297
265 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {298 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
266 for (const image of images) {299 for (const image of images) {
267 if (!image.previewUrl) continue;300 if (!image.previewUrl) continue;
...@@ -275,6 +308,11 @@ export default function ChatView() {...@@ -275,6 +308,11 @@ export default function ChatView() {
275 const cursor = textarea.value.length;308 const cursor = textarea.value.length;
276 textarea.setSelectionRange(cursor, cursor);309 textarea.setSelectionRange(cursor, cursor);
277 }, []);310 }, []);
311 const scheduleComposerFocus = useCallback(() => {
312 window.requestAnimationFrame(() => {
313 focusComposer();
314 });
315 }, [focusComposer]);
278 const toggleTerminalVisibility = useCallback(() => {316 const toggleTerminalVisibility = useCallback(() => {
279 if (!activeThreadId) return;317 if (!activeThreadId) return;
280 dispatch({318 dispatch({
...@@ -346,6 +384,7 @@ export default function ChatView() {...@@ -346,6 +384,7 @@ export default function ChatView() {
346 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {384 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
347 if (mode === state.runtimeMode) return;385 if (mode === state.runtimeMode) return;
348 dispatch({ type: "SET_RUNTIME_MODE", mode });386 dispatch({ type: "SET_RUNTIME_MODE", mode });
387 scheduleComposerFocus();
349 if (!api) return;388 if (!api) return;
350389
351 const sessionIds = state.threads390 const sessionIds = state.threads
...@@ -463,7 +502,7 @@ export default function ChatView() {...@@ -463,7 +502,7 @@ export default function ChatView() {
463 if (phase !== "running") return;502 if (phase !== "running") return;
464 const timer = window.setInterval(() => {503 const timer = window.setInterval(() => {
465 setNowTick(Date.now());504 setNowTick(Date.now());
466 }, 250);505 }, 1000);
467 return () => {506 return () => {
468 window.clearInterval(timer);507 window.clearInterval(timer);
469 };508 };
...@@ -490,15 +529,74 @@ export default function ChatView() {...@@ -490,15 +529,74 @@ export default function ChatView() {
490 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);529 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
491530
492 useEffect(() => {531 useEffect(() => {
532 const isTerminalFocused = (): boolean => {
533 const activeElement = document.activeElement;
534 if (!(activeElement instanceof HTMLElement)) return false;
535 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
536 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
537 };
538
493 const handler = (event: globalThis.KeyboardEvent) => {539 const handler = (event: globalThis.KeyboardEvent) => {
494 if (!activeThreadId) return;540 if (!activeThreadId || event.defaultPrevented) return;
495 if (!isTerminalToggleShortcut(event)) return;541 const shortcutContext = {
542 terminalFocus: isTerminalFocused(),
543 terminalOpen: Boolean(activeThread?.terminalOpen),
544 };
545
546 if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
547 event.preventDefault();
548 event.stopPropagation();
549 toggleTerminalVisibility();
550 return;
551 }
552
553 if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
554 event.preventDefault();
555 event.stopPropagation();
556 if (!activeThread?.terminalOpen) {
557 dispatch({
558 type: "SET_THREAD_TERMINAL_OPEN",
559 threadId: activeThreadId,
560 open: true,
561 });
562 }
563 splitTerminal();
564 return;
565 }
566
567 if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
568 event.preventDefault();
569 event.stopPropagation();
570 if (!activeThread?.terminalOpen) return;
571 closeTerminal(activeThread.activeTerminalId);
572 return;
573 }
574
575 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
496 event.preventDefault();576 event.preventDefault();
497 toggleTerminalVisibility();577 event.stopPropagation();
578 if (!activeThread?.terminalOpen) {
579 dispatch({
580 type: "SET_THREAD_TERMINAL_OPEN",
581 threadId: activeThreadId,
582 open: true,
583 });
584 }
585 createNewTerminal();
498 };586 };
499 window.addEventListener("keydown", handler);587 window.addEventListener("keydown", handler);
500 return () => window.removeEventListener("keydown", handler);588 return () => window.removeEventListener("keydown", handler);
501 }, [activeThreadId, toggleTerminalVisibility]);589 }, [
590 activeThread?.terminalOpen,
591 activeThread?.activeTerminalId,
592 activeThreadId,
593 closeTerminal,
594 createNewTerminal,
595 dispatch,
596 splitTerminal,
597 keybindings,
598 toggleTerminalVisibility,
599 ]);
502600
503 const setThreadError = (threadId: string | null, error: string | null) => {601 const setThreadError = (threadId: string | null, error: string | null) => {
504 if (!threadId) return;602 if (!threadId) return;
...@@ -796,37 +894,58 @@ export default function ChatView() {...@@ -796,37 +894,58 @@ export default function ChatView() {
796 });894 });
797 };895 };
798896
799 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {897 const onRespondToApproval = useCallback(
800 if (!api || !activeThread?.session) return;898 async (requestId: string, decision: ProviderApprovalDecision) => {
899 if (!api || !activeSessionId || !activeThreadId) return;
801900
802 setRespondingRequestIds((existing) =>901 setRespondingRequestIds((existing) =>
803 existing.includes(requestId) ? existing : [...existing, requestId],902 existing.includes(requestId) ? existing : [...existing, requestId],
804 );903 );
805 try {904 try {
806 await api.providers.respondToRequest({905 await api.providers.respondToRequest({
807 sessionId: activeThread.session.sessionId,906 sessionId: activeSessionId,
808 requestId,907 requestId,
809 decision,908 decision,
810 });909 });
811 } catch (err) {910 } catch (err) {
911 dispatch({
912 type: "SET_ERROR",
913 threadId: activeThreadId,
914 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
915 });
916 } finally {
917 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
918 }
919 },
920 [activeSessionId, activeThreadId, api, dispatch],
921 );
922
923 const onModelSelect = useCallback(
924 (model: ModelSlug) => {
925 if (!activeThread) return;
812 dispatch({926 dispatch({
813 type: "SET_ERROR",927 type: "SET_THREAD_MODEL",
814 threadId: activeThread.id,928 threadId: activeThread.id,
815 error: err instanceof Error ? err.message : "Failed to submit approval decision.",929 model: resolveModelSlug(model),
816 });930 });
817 } finally {931 scheduleComposerFocus();
818 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));932 },
819 }933 [activeThread, dispatch, scheduleComposerFocus],
820 };934 );
821935 const onEffortSelect = useCallback(
822 const onModelSelect = (model: ModelSlug) => {936 (effort: ReasoningEffort) => {
823 if (!activeThread) return;937 setSelectedEffort(effort);
824 dispatch({938 scheduleComposerFocus();
825 type: "SET_THREAD_MODEL",939 },
826 threadId: activeThread.id,940 [scheduleComposerFocus],
827 model: resolveModelSlug(model),941 );
828 });942 const onEnvModeChange = useCallback(
829 };943 (mode: "local" | "worktree") => {
944 setEnvMode(mode);
945 scheduleComposerFocus();
946 },
947 [scheduleComposerFocus],
948 );
830949
831 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {950 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
832 if (e.key === "Enter" && !e.shiftKey) {951 if (e.key === "Enter" && !e.shiftKey) {
...@@ -834,6 +953,18 @@ export default function ChatView() {...@@ -834,6 +953,18 @@ export default function ChatView() {
834 void onSend(e as unknown as FormEvent);953 void onSend(e as unknown as FormEvent);
835 }954 }
836 };955 };
956 const onToggleWorkGroup = useCallback((groupId: string) => {
957 setExpandedWorkGroups((existing) => ({
958 ...existing,
959 [groupId]: !existing[groupId],
960 }));
961 }, []);
962 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
963 setExpandedImage(image);
964 }, []);
965 const onToggleDiff = useCallback(() => {
966 dispatch({ type: "TOGGLE_DIFF" });
967 }, [dispatch]);
837968
838 // Empty state: no active thread969 // Empty state: no active thread
839 if (!activeThread) {970 if (!activeThread) {
...@@ -859,101 +990,24 @@ export default function ChatView() {...@@ -859,101 +990,24 @@ export default function ChatView() {
859 <header990 <header
860 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}991 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
861 >992 >
862 <div className="flex items-center gap-3">993 <ChatHeader
863 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>994 activeThreadTitle={activeThread.title}
864 {activeProject && <Badge variant="outline">{activeProject.name}</Badge>}995 activeProjectName={activeProject?.name}
865 </div>996 keybindings={keybindings}
866 <div className="flex items-center gap-3">997 api={api}
867 {/* Open in editor */}998 gitCwd={gitCwd}
868 {activeProject && <OpenInPicker />}999 diffOpen={state.diffOpen}
869 {/* Git actions */}1000 onToggleDiff={onToggleDiff}
870 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}1001 />
871
872 {/* Diff toggle */}
873 <Button
874 size="xs"
875 variant="ghost"
876 className={cn(
877 "text-muted-foreground/70 hover:text-foreground/80",
878 state.diffOpen && "bg-accent text-accent-foreground",
879 )}
880 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
881 >
882 Diff
883 </Button>
884 </div>
885 </header>1002 </header>
8861003
887 {/* Error banner */}1004 {/* Error banner */}
888 {activeThread.error && (1005 <ThreadErrorBanner error={activeThread.error} />
889 <div className="pt-3 mx-auto max-w-3xl">1006 <PendingApprovalsPanel
890 <Alert variant="error">1007 pendingApprovals={pendingApprovals}
891 <CircleAlertIcon />1008 respondingRequestIds={respondingRequestIds}
892 <AlertDescription className="line-clamp-3" title={activeThread.error}>1009 onRespondToApproval={onRespondToApproval}
893 {activeThread.error}1010 />
894 </AlertDescription>
895 </Alert>
896 </div>
897 )}
898
899 {pendingApprovals.length > 0 && (
900 <div className="pt-3 mx-auto max-w-3xl space-y-2">
901 {pendingApprovals.map((approval) => {
902 const isResponding = respondingRequestIds.includes(approval.requestId);
903
904 return (
905 <Alert variant="warning" key={approval.requestId}>
906 <InfoIcon />
907 <AlertTitle className="text-xs">
908 {approval.requestKind === "command"
909 ? "Command approval requested"
910 : "File-change approval requested"}
911 </AlertTitle>
912 <AlertDescription
913 className="truncate block font-mono text-[11px]"
914 title={approval.detail}
915 >
916 {approval.detail}
917 </AlertDescription>
918 <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto">
919 <Button
920 size="xs"
921 variant="default"
922 disabled={isResponding}
923 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
924 >
925 Approve once
926 </Button>
927 <Button
928 size="xs"
929 variant="outline"
930 disabled={isResponding}
931 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
932 >
933 Always allow this session
934 </Button>
935 <Button
936 size="xs"
937 variant="destructive-outline"
938 disabled={isResponding}
939 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
940 >
941 Decline
942 </Button>
943 <Button
944 size="xs"
945 variant="ghost"
946 disabled={isResponding}
947 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
948 >
949 Cancel turn
950 </Button>
951 </AlertAction>
952 </Alert>
953 );
954 })}
955 </div>
956 )}
9571011
958 {/* Messages */}1012 {/* Messages */}
959 <div1013 <div
...@@ -961,198 +1015,19 @@ export default function ChatView() {...@@ -961,198 +1015,19 @@ export default function ChatView() {
961 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"1015 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
962 onScroll={onMessagesScroll}1016 onScroll={onMessagesScroll}
963 >1017 >
964 {activeThread.messages.length === 0 && !isWorking ? (1018 <MessagesTimeline
965 <div className="flex h-full items-center justify-center">1019 hasMessages={activeThread.messages.length > 0}
966 <p className="text-sm text-muted-foreground/30">1020 isWorking={isWorking}
967 Send a message to start the conversation.1021 timelineEntries={timelineEntries}
968 </p>1022 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
969 </div>1023 completionSummary={completionSummary}
970 ) : (1024 assistantCompletionByItemId={assistantCompletionByItemId}
971 <div className="mx-auto max-w-3xl space-y-4">1025 nowIso={nowIso}
972 {timelineEntries.map((timelineEntry, index) => {1026 expandedWorkGroups={expandedWorkGroups}
973 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {1027 onToggleWorkGroup={onToggleWorkGroup}
974 return null;1028 onImageExpand={onExpandTimelineImage}
975 }1029 messagesEndRef={messagesEndRef}
9761030 />
977 const showCompletionDivider =
978 timelineEntry.kind === "message" &&
979 timelineEntry.message.role === "assistant" &&
980 completionDividerBeforeEntryId === timelineEntry.id;
981
982 if (timelineEntry.kind === "work") {
983 const groupedEntries = [timelineEntry.entry];
984 let cursor = index + 1;
985 while (cursor < timelineEntries.length) {
986 const nextEntry = timelineEntries[cursor];
987 if (!nextEntry || nextEntry.kind !== "work") break;
988 groupedEntries.push(nextEntry.entry);
989 cursor += 1;
990 }
991
992 const groupId = timelineEntry.id;
993 const isExpanded = expandedWorkGroups[groupId] ?? false;
994 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
995 const visibleEntries =
996 hasOverflow && !isExpanded
997 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
998 : groupedEntries;
999 const hiddenCount = groupedEntries.length - visibleEntries.length;
1000 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
1001 const groupLabel = onlyToolEntries
1002 ? groupedEntries.length === 1
1003 ? "Tool call"
1004 : `Tool calls (${groupedEntries.length})`
1005 : groupedEntries.length === 1
1006 ? "Work event"
1007 : `Work log (${groupedEntries.length})`;
1008
1009 return (
1010 <Fragment key={timelineEntry.id}>
1011 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
1012 <div className="mb-1.5 flex items-center justify-between gap-3">
1013 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1014 {groupLabel}
1015 </p>
1016 {hasOverflow && (
1017 <button
1018 type="button"
1019 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
1020 onClick={() =>
1021 setExpandedWorkGroups((existing) => ({
1022 ...existing,
1023 [groupId]: !existing[groupId],
1024 }))
1025 }
1026 >
1027 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
1028 </button>
1029 )}
1030 </div>
1031 <div className="space-y-1">
1032 {visibleEntries.map((workEntry) => (
1033 <div
1034 key={`work-row:${workEntry.id}`}
1035 className="flex items-start gap-2 py-0.5"
1036 >
1037 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1038 <p
1039 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
1040 >
1041 {workEntry.detail ? (
1042 <>
1043 {workEntry.label}
1044 <span
1045 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
1046 title={workEntry.detail}
1047 >
1048 {workEntry.detail}
1049 </span>
1050 </>
1051 ) : (
1052 workEntry.label
1053 )}
1054 </p>
1055 </div>
1056 ))}
1057 </div>
1058 </div>
1059 </Fragment>
1060 );
1061 }
1062
1063 if (timelineEntry.message.role === "user") {
1064 const userImages = timelineEntry.message.attachments ?? [];
1065 return (
1066 <Fragment key={timelineEntry.id}>
1067 <div className="flex justify-end">
1068 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
1069 {userImages.length > 0 && (
1070 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1071 {userImages.map((image) => (
1072 <div
1073 key={image.id}
1074 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1075 >
1076 {image.previewUrl ? (
1077 <img
1078 src={image.previewUrl}
1079 alt={image.name}
1080 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1081 onClick={() =>
1082 setExpandedImage({ src: image.previewUrl!, name: image.name })
1083 }
1084 />
1085 ) : (
1086 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1087 {image.name}
1088 </div>
1089 )}
1090 </div>
1091 ))}
1092 </div>
1093 )}
1094 {timelineEntry.message.text && (
1095 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
1096 {timelineEntry.message.text}
1097 </pre>
1098 )}
1099 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
1100 {formatTimestamp(timelineEntry.message.createdAt)}
1101 </p>
1102 </div>
1103 </div>
1104 </Fragment>
1105 );
1106 }
1107
1108 return (
1109 <Fragment key={timelineEntry.id}>
1110 {showCompletionDivider && (
1111 <div className="my-3 flex items-center gap-3">
1112 <span className="h-px flex-1 bg-border" />
1113 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
1114 {completionSummary ? `Response • ${completionSummary}` : "Response"}
1115 </span>
1116 <span className="h-px flex-1 bg-border" />
1117 </div>
1118 )}
1119 <div className="px-1 py-0.5">
1120 <ChatMarkdown
1121 text={
1122 timelineEntry.message.text ||
1123 (timelineEntry.message.streaming ? "" : "(empty response)")
1124 }
1125 />
1126 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
1127 {formatMessageMeta(
1128 timelineEntry.message.createdAt,
1129 timelineEntry.message.streaming
1130 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
1131 : formatElapsed(
1132 timelineEntry.message.createdAt,
1133 assistantCompletionByItemId.get(timelineEntry.message.id),
1134 ),
1135 )}
1136 </p>
1137 </div>
1138 </Fragment>
1139 );
1140 })}
1141 {isWorking && (
1142 <div className="flex items-center gap-2 py-0.5 pl-1.5">
1143 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1144 <div className="flex items-center pt-1">
1145 <span className="inline-flex items-center gap-[3px]">
1146 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1147 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1148 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1149 </span>
1150 </div>
1151 </div>
1152 )}
1153 <div ref={messagesEndRef} />
1154 </div>
1155 )}
1156 </div>1031 </div>
11571032
1158 {/* Input bar */}1033 {/* Input bar */}
...@@ -1230,7 +1105,7 @@ export default function ChatView() {...@@ -1230,7 +1105,7 @@ export default function ChatView() {
1230 <Separator orientation="vertical" className="mx-0.5 h-4" />1105 <Separator orientation="vertical" className="mx-0.5 h-4" />
12311106
1232 {/* Reasoning effort */}1107 {/* Reasoning effort */}
1233 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={setSelectedEffort} />1108 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} />
12341109
1235 {/* Divider */}1110 {/* Divider */}
1236 <Separator orientation="vertical" className="mx-0.5 h-4" />1111 <Separator orientation="vertical" className="mx-0.5 h-4" />
...@@ -1333,7 +1208,12 @@ export default function ChatView() {...@@ -1333,7 +1208,12 @@ export default function ChatView() {
1333 </div>1208 </div>
13341209
1335 {isGitRepo && (1210 {isGitRepo && (
1336 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />1211 <BranchToolbar
1212 envMode={envMode}
1213 onEnvModeChange={onEnvModeChange}
1214 envLocked={envLocked}
1215 onComposerFocusRequest={scheduleComposerFocus}
1216 />
1337 )}1217 )}
13381218
1339 {activeThread.terminalOpen && api && activeProject && (1219 {activeThread.terminalOpen && api && activeProject && (
...@@ -1350,6 +1230,9 @@ export default function ChatView() {...@@ -1350,6 +1230,9 @@ export default function ChatView() {
1350 focusRequestId={terminalFocusRequestId}1230 focusRequestId={terminalFocusRequestId}
1351 onSplitTerminal={splitTerminal}1231 onSplitTerminal={splitTerminal}
1352 onNewTerminal={createNewTerminal}1232 onNewTerminal={createNewTerminal}
1233 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1234 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1235 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
1353 onActiveTerminalChange={activateTerminal}1236 onActiveTerminalChange={activateTerminal}
1354 onCloseTerminal={closeTerminal}1237 onCloseTerminal={closeTerminal}
1355 onHeightChange={(height) =>1238 onHeightChange={(height) =>
...@@ -1400,7 +1283,355 @@ export default function ChatView() {...@@ -1400,7 +1283,355 @@ export default function ChatView() {
1400 );1283 );
1401}1284}
14021285
1403function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) {1286interface ChatHeaderProps {
1287 activeThreadTitle: string;
1288 activeProjectName: string | undefined;
1289 keybindings: ResolvedKeybindingsConfig;
1290 api: NativeApi | undefined;
1291 gitCwd: string | null;
1292 diffOpen: boolean;
1293 onToggleDiff: () => void;
1294}
1295
1296const ChatHeader = memo(function ChatHeader({
1297 activeThreadTitle,
1298 activeProjectName,
1299 keybindings,
1300 api,
1301 gitCwd,
1302 diffOpen,
1303 onToggleDiff,
1304}: ChatHeaderProps) {
1305 return (
1306 <>
1307 <div className="flex items-center gap-3">
1308 <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2>
1309 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
1310 </div>
1311 <div className="flex items-center gap-3">
1312 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
1313 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1314 <Button
1315 size="xs"
1316 variant="ghost"
1317 className={cn(
1318 "text-muted-foreground/70 hover:text-foreground/80",
1319 diffOpen && "bg-accent text-accent-foreground",
1320 )}
1321 onClick={onToggleDiff}
1322 >
1323 Diff
1324 </Button>
1325 </div>
1326 </>
1327 );
1328});
1329
1330const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) {
1331 if (!error) return null;
1332 return (
1333 <div className="pt-3 mx-auto max-w-3xl">
1334 <Alert variant="error">
1335 <CircleAlertIcon />
1336 <AlertDescription className="line-clamp-3" title={error}>
1337 {error}
1338 </AlertDescription>
1339 </Alert>
1340 </div>
1341 );
1342});
1343
1344interface PendingApprovalsPanelProps {
1345 pendingApprovals: PendingApproval[];
1346 respondingRequestIds: string[];
1347 onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>;
1348}
1349
1350const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
1351 pendingApprovals,
1352 respondingRequestIds,
1353 onRespondToApproval,
1354}: PendingApprovalsPanelProps) {
1355 if (pendingApprovals.length === 0) return null;
1356 return (
1357 <div className="pt-3 mx-auto max-w-3xl space-y-2">
1358 {pendingApprovals.map((approval) => {
1359 const isResponding = respondingRequestIds.includes(approval.requestId);
1360
1361 return (
1362 <Alert variant="warning" key={approval.requestId}>
1363 <InfoIcon />
1364 <AlertTitle className="text-xs">
1365 {approval.requestKind === "command"
1366 ? "Command approval requested"
1367 : "File-change approval requested"}
1368 </AlertTitle>
1369 <AlertDescription
1370 className="truncate block font-mono text-[11px]"
1371 title={approval.detail}
1372 >
1373 {approval.detail}
1374 </AlertDescription>
1375 <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto">
1376 <Button
1377 size="xs"
1378 variant="default"
1379 disabled={isResponding}
1380 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
1381 >
1382 Approve once
1383 </Button>
1384 <Button
1385 size="xs"
1386 variant="outline"
1387 disabled={isResponding}
1388 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
1389 >
1390 Always allow this session
1391 </Button>
1392 <Button
1393 size="xs"
1394 variant="destructive-outline"
1395 disabled={isResponding}
1396 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
1397 >
1398 Decline
1399 </Button>
1400 <Button
1401 size="xs"
1402 variant="ghost"
1403 disabled={isResponding}
1404 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
1405 >
1406 Cancel turn
1407 </Button>
1408 </AlertAction>
1409 </Alert>
1410 );
1411 })}
1412 </div>
1413 );
1414});
1415
1416interface MessagesTimelineProps {
1417 hasMessages: boolean;
1418 isWorking: boolean;
1419 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
1420 completionDividerBeforeEntryId: string | null;
1421 completionSummary: string | null;
1422 assistantCompletionByItemId: Map<string, string>;
1423 nowIso: string;
1424 expandedWorkGroups: Record<string, boolean>;
1425 onToggleWorkGroup: (groupId: string) => void;
1426 onImageExpand: (image: ExpandedImagePreview) => void;
1427 messagesEndRef: RefObject<HTMLDivElement | null>;
1428}
1429
1430const MessagesTimeline = memo(function MessagesTimeline({
1431 hasMessages,
1432 isWorking,
1433 timelineEntries,
1434 completionDividerBeforeEntryId,
1435 completionSummary,
1436 assistantCompletionByItemId,
1437 nowIso,
1438 expandedWorkGroups,
1439 onToggleWorkGroup,
1440 onImageExpand,
1441 messagesEndRef,
1442}: MessagesTimelineProps) {
1443 if (!hasMessages && !isWorking) {
1444 return (
1445 <div className="flex h-full items-center justify-center">
1446 <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p>
1447 </div>
1448 );
1449 }
1450
1451 return (
1452 <div className="mx-auto max-w-3xl space-y-4">
1453 {timelineEntries.map((timelineEntry, index) => {
1454 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
1455 return null;
1456 }
1457
1458 const showCompletionDivider =
1459 timelineEntry.kind === "message" &&
1460 timelineEntry.message.role === "assistant" &&
1461 completionDividerBeforeEntryId === timelineEntry.id;
1462
1463 if (timelineEntry.kind === "work") {
1464 const groupedEntries = [timelineEntry.entry];
1465 let cursor = index + 1;
1466 while (cursor < timelineEntries.length) {
1467 const nextEntry = timelineEntries[cursor];
1468 if (!nextEntry || nextEntry.kind !== "work") break;
1469 groupedEntries.push(nextEntry.entry);
1470 cursor += 1;
1471 }
1472
1473 const groupId = timelineEntry.id;
1474 const isExpanded = expandedWorkGroups[groupId] ?? false;
1475 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
1476 const visibleEntries =
1477 hasOverflow && !isExpanded
1478 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
1479 : groupedEntries;
1480 const hiddenCount = groupedEntries.length - visibleEntries.length;
1481 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
1482 const groupLabel = onlyToolEntries
1483 ? groupedEntries.length === 1
1484 ? "Tool call"
1485 : `Tool calls (${groupedEntries.length})`
1486 : groupedEntries.length === 1
1487 ? "Work event"
1488 : `Work log (${groupedEntries.length})`;
1489
1490 return (
1491 <Fragment key={timelineEntry.id}>
1492 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
1493 <div className="mb-1.5 flex items-center justify-between gap-3">
1494 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1495 {groupLabel}
1496 </p>
1497 {hasOverflow && (
1498 <button
1499 type="button"
1500 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
1501 onClick={() => onToggleWorkGroup(groupId)}
1502 >
1503 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
1504 </button>
1505 )}
1506 </div>
1507 <div className="space-y-1">
1508 {visibleEntries.map((workEntry) => (
1509 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
1510 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1511 <p
1512 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
1513 >
1514 {workEntry.detail ? (
1515 <>
1516 {workEntry.label}
1517 <span
1518 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
1519 title={workEntry.detail}
1520 >
1521 {workEntry.detail}
1522 </span>
1523 </>
1524 ) : (
1525 workEntry.label
1526 )}
1527 </p>
1528 </div>
1529 ))}
1530 </div>
1531 </div>
1532 </Fragment>
1533 );
1534 }
1535
1536 if (timelineEntry.message.role === "user") {
1537 const userImages = timelineEntry.message.attachments ?? [];
1538 return (
1539 <Fragment key={timelineEntry.id}>
1540 <div className="flex justify-end">
1541 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
1542 {userImages.length > 0 && (
1543 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1544 {userImages.map((image) => (
1545 <div
1546 key={image.id}
1547 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1548 >
1549 {image.previewUrl ? (
1550 <img
1551 src={image.previewUrl}
1552 alt={image.name}
1553 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1554 onClick={() =>
1555 onImageExpand({ src: image.previewUrl!, name: image.name })
1556 }
1557 />
1558 ) : (
1559 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1560 {image.name}
1561 </div>
1562 )}
1563 </div>
1564 ))}
1565 </div>
1566 )}
1567 {timelineEntry.message.text && (
1568 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
1569 {timelineEntry.message.text}
1570 </pre>
1571 )}
1572 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
1573 {formatTimestamp(timelineEntry.message.createdAt)}
1574 </p>
1575 </div>
1576 </div>
1577 </Fragment>
1578 );
1579 }
1580
1581 return (
1582 <Fragment key={timelineEntry.id}>
1583 {showCompletionDivider && (
1584 <div className="my-3 flex items-center gap-3">
1585 <span className="h-px flex-1 bg-border" />
1586 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
1587 {completionSummary ? `Response • ${completionSummary}` : "Response"}
1588 </span>
1589 <span className="h-px flex-1 bg-border" />
1590 </div>
1591 )}
1592 <div className="px-1 py-0.5">
1593 <ChatMarkdown
1594 text={
1595 timelineEntry.message.text ||
1596 (timelineEntry.message.streaming ? "" : "(empty response)")
1597 }
1598 />
1599 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
1600 {formatMessageMeta(
1601 timelineEntry.message.createdAt,
1602 timelineEntry.message.streaming
1603 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
1604 : formatElapsed(
1605 timelineEntry.message.createdAt,
1606 assistantCompletionByItemId.get(timelineEntry.message.id),
1607 ),
1608 )}
1609 </p>
1610 </div>
1611 </Fragment>
1612 );
1613 })}
1614 {isWorking && (
1615 <div className="flex items-center gap-2 py-0.5 pl-1.5">
1616 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1617 <div className="flex items-center pt-1">
1618 <span className="inline-flex items-center gap-[3px]">
1619 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1620 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1621 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1622 </span>
1623 </div>
1624 </div>
1625 )}
1626 <div ref={messagesEndRef} />
1627 </div>
1628 );
1629});
1630
1631const ModelPicker = memo(function ModelPicker(props: {
1632 model: ModelSlug;
1633 onModelChange: (model: ModelSlug) => void;
1634}) {
1404 return (1635 return (
1405 <Select1636 <Select
1406 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}1637 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
...@@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug...@@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug
1419 </SelectPopup>1650 </SelectPopup>
1420 </Select>1651 </Select>
1421 );1652 );
1422}1653});
14231654
1424function ReasoningEffortPicker(props: {1655const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
1425 effort: ReasoningEffort;1656 effort: ReasoningEffort;
1426 onEffortChange: (effort: ReasoningEffort) => void;1657 onEffortChange: (effort: ReasoningEffort) => void;
1427}) {1658}) {
...@@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: {...@@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: {
1443 </SelectPopup>1674 </SelectPopup>
1444 </Select>1675 </Select>
1445 );1676 );
1446}1677});
14471678
1448function OpenInPicker() {1679const OpenInPicker = memo(function OpenInPicker({
1680 keybindings,
1681}: {
1682 keybindings: ResolvedKeybindingsConfig;
1683}) {
1449 const [lastEditor, setLastEditor] = useState<EditorId>(() => {1684 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
1450 const stored = localStorage.getItem(LAST_EDITOR_KEY);1685 const stored = localStorage.getItem(LAST_EDITOR_KEY);
1451 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;1686 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
...@@ -1458,9 +1693,9 @@ function OpenInPicker() {...@@ -1458,9 +1693,9 @@ function OpenInPicker() {
1458 value: "cursor",1693 value: "cursor",
1459 },1694 },
1460 {1695 {
1461 label: navigator.platform.includes("Mac")1696 label: isMacPlatform(navigator.platform)
1462 ? "Finder"1697 ? "Finder"
1463 : navigator.platform.includes("Win")1698 : isWindowsPlatform(navigator.platform)
1464 ? "Explorer"1699 ? "Explorer"
1465 : "Files",1700 : "Files",
1466 Icon: FolderClosedIcon,1701 Icon: FolderClosedIcon,
...@@ -1486,20 +1721,23 @@ function OpenInPicker() {...@@ -1486,20 +1721,23 @@ function OpenInPicker() {
1486 [api, activeProject, activeThread, lastEditor, setLastEditor],1721 [api, activeProject, activeThread, lastEditor, setLastEditor],
1487 );1722 );
14881723
1489 // Cmd+O / Ctrl+O to open in last-used editor1724 const openFavoriteEditorShortcutLabel = useMemo(
1725 () => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
1726 [keybindings],
1727 );
1728
1490 useEffect(() => {1729 useEffect(() => {
1491 const handler = (e: globalThis.KeyboardEvent) => {1730 const handler = (e: globalThis.KeyboardEvent) => {
1492 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {1731 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
1493 if (api && activeProject) {1732 if (!api || !activeProject) return;
1494 e.preventDefault();1733
1495 const cwd = activeThread?.worktreePath ?? activeProject.cwd;1734 e.preventDefault();
1496 void api.shell.openInEditor(cwd, lastEditor);1735 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1497 }1736 void api.shell.openInEditor(cwd, lastEditor);
1498 }
1499 };1737 };
1500 window.addEventListener("keydown", handler);1738 window.addEventListener("keydown", handler);
1501 return () => window.removeEventListener("keydown", handler);1739 return () => window.removeEventListener("keydown", handler);
1502 }, [api, activeProject, activeThread, lastEditor]);1740 }, [api, activeProject, activeThread, keybindings, lastEditor]);
15031741
1504 return (1742 return (
1505 <Group aria-label="Subscription actions">1743 <Group aria-label="Subscription actions">
...@@ -1517,10 +1755,8 @@ function OpenInPicker() {...@@ -1517,10 +1755,8 @@ function OpenInPicker() {
1517 <MenuItem key={value} onClick={() => openInEditor(value)}>1755 <MenuItem key={value} onClick={() => openInEditor(value)}>
1518 <Icon aria-hidden="true" className="text-muted-foreground" />1756 <Icon aria-hidden="true" className="text-muted-foreground" />
1519 {label}1757 {label}
1520 {value === lastEditor && (1758 {value === lastEditor && openFavoriteEditorShortcutLabel && (
1521 <MenuShortcut>1759 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
1522 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
1523 </MenuShortcut>
1524 )}1760 )}
1525 </MenuItem>1761 </MenuItem>
1526 ))}1762 ))}
...@@ -1528,4 +1764,4 @@ function OpenInPicker() {...@@ -1528,4 +1764,4 @@ function OpenInPicker() {
1528 </Menu>1764 </Menu>
1529 </Group>1765 </Group>
1530 );1766 );
1531}1767});
apps/web/src/components/GitActionsControl.logic.test.ts+23-17
...@@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {...@@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
20 hasUpstream: true,20 hasUpstream: true,
21 aheadCount: 0,21 aheadCount: 0,
22 behindCount: 0,22 behindCount: 0,
23 openPr: null,23 pr: null,
24 mergedPr: null,
25 ...overrides,24 ...overrides,
26 };25 };
27}26}
...@@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {...@@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {
30 it("resolveQuickAction opens the existing PR", () => {29 it("resolveQuickAction opens the existing PR", () => {
31 const quick = resolveQuickAction(30 const quick = resolveQuickAction(
32 status({31 status({
33 openPr: {32 pr: {
34 number: 10,33 number: 10,
35 title: "Open PR",34 title: "Open PR",
36 url: "https://example.com/pr/10",35 url: "https://example.com/pr/10",
37 baseBranch: "main",36 baseBranch: "main",
38 headBranch: "feature/test",37 headBranch: "feature/test",
38 state: "open",
39 },39 },
40 }),40 }),
41 false,41 false,
...@@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {...@@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {
46 it("buildMenuItems disables commit/push and enables open PR", () => {46 it("buildMenuItems disables commit/push and enables open PR", () => {
47 const items = buildMenuItems(47 const items = buildMenuItems(
48 status({48 status({
49 openPr: {49 pr: {
50 number: 11,50 number: 11,
51 title: "Existing PR",51 title: "Existing PR",
52 url: "https://example.com/pr/11",52 url: "https://example.com/pr/11",
53 baseBranch: "main",53 baseBranch: "main",
54 headBranch: "feature/test",54 headBranch: "feature/test",
55 state: "open",
55 },56 },
56 }),57 }),
57 false,58 false,
...@@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
148 const quick = resolveQuickAction(149 const quick = resolveQuickAction(
149 status({150 status({
150 aheadCount: 3,151 aheadCount: 3,
151 openPr: {152 pr: {
152 number: 13,153 number: 13,
153 title: "Open PR",154 title: "Open PR",
154 url: "https://example.com/pr/13",155 url: "https://example.com/pr/13",
155 baseBranch: "main",156 baseBranch: "main",
156 headBranch: "feature/test",157 headBranch: "feature/test",
158 state: "open",
157 },159 },
158 }),160 }),
159 false,161 false,
...@@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
165 const items = buildMenuItems(167 const items = buildMenuItems(
166 status({168 status({
167 aheadCount: 2,169 aheadCount: 2,
168 openPr: {170 pr: {
169 number: 12,171 number: 12,
170 title: "Existing PR",172 title: "Existing PR",
171 url: "https://example.com/pr/12",173 url: "https://example.com/pr/12",
172 baseBranch: "main",174 baseBranch: "main",
173 headBranch: "feature/test",175 headBranch: "feature/test",
176 state: "open",
174 },177 },
175 }),178 }),
176 false,179 false,
...@@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
205208
206describe("when: branch is clean, ahead, and has no open PR", () => {209describe("when: branch is clean, ahead, and has no open PR", () => {
207 it("resolveQuickAction pushes and creates a PR", () => {210 it("resolveQuickAction pushes and creates a PR", () => {
208 const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false);211 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
209 assert.deepInclude(quick, {212 assert.deepInclude(quick, {
210 kind: "run_action",213 kind: "run_action",
211 action: "commit_push_pr",214 action: "commit_push_pr",
...@@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {...@@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
214 });217 });
215218
216 it("buildMenuItems enables push and create PR, with commit disabled", () => {219 it("buildMenuItems enables push and create PR, with commit disabled", () => {
217 const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false);220 const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false);
218 assert.deepEqual(items, [221 assert.deepEqual(items, [
219 {222 {
220 id: "commit",223 id: "commit",
...@@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {...@@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
247describe("when: branch is clean, up to date, and has no open PR", () => {250describe("when: branch is clean, up to date, and has no open PR", () => {
248 it("resolveQuickAction returns disabled no-action state", () => {251 it("resolveQuickAction returns disabled no-action state", () => {
249 const quick = resolveQuickAction(252 const quick = resolveQuickAction(
250 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }),253 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
251 false,254 false,
252 );255 );
253 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });256 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
254 });257 });
255258
256 it("buildMenuItems disables commit, push, and create PR", () => {259 it("buildMenuItems disables commit, push, and create PR", () => {
257 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false);260 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false);
258 assert.deepEqual(items, [261 assert.deepEqual(items, [
259 {262 {
260 id: "commit",263 id: "commit",
...@@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => {...@@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => {
291 });294 });
292295
293 it("buildMenuItems disables push and create PR", () => {296 it("buildMenuItems disables push and create PR", () => {
294 const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false);297 const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
295 assert.deepEqual(items, [298 assert.deepEqual(items, [
296 {299 {
297 id: "commit",300 id: "commit",
...@@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => {...@@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => {
347 const quick = resolveQuickAction(350 const quick = resolveQuickAction(
348 status({351 status({
349 hasWorkingTreeChanges: true,352 hasWorkingTreeChanges: true,
350 openPr: {353 pr: {
351 number: 16,354 number: 16,
352 title: "Existing PR",355 title: "Existing PR",
353 url: "https://example.com/pr/16",356 url: "https://example.com/pr/16",
354 baseBranch: "main",357 baseBranch: "main",
355 headBranch: "feature/test",358 headBranch: "feature/test",
359 state: "open",
356 },360 },
357 }),361 }),
358 false,362 false,
...@@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => {...@@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => {
412416
413 it("resolveQuickAction returns push when branch is ahead", () => {417 it("resolveQuickAction returns push when branch is ahead", () => {
414 const quick = resolveQuickAction(418 const quick = resolveQuickAction(
415 status({ branch: "main", aheadCount: 2, openPr: null }),419 status({ branch: "main", aheadCount: 2, pr: null }),
416 false,420 false,
417 true,421 true,
418 );422 );
...@@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {...@@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
512describe("when: branch has no upstream configured", () => {516describe("when: branch has no upstream configured", () => {
513 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {517 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
514 const quick = resolveQuickAction(518 const quick = resolveQuickAction(
515 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),519 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
516 false,520 false,
517 );521 );
518 assert.deepInclude(quick, {522 assert.deepInclude(quick, {
...@@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => {...@@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => {
527 status({531 status({
528 hasUpstream: false,532 hasUpstream: false,
529 aheadCount: 0,533 aheadCount: 0,
530 openPr: {534 pr: {
531 number: 14,535 number: 14,
532 title: "Existing PR",536 title: "Existing PR",
533 url: "https://example.com/pr/14",537 url: "https://example.com/pr/14",
534 baseBranch: "main",538 baseBranch: "main",
535 headBranch: "feature/test",539 headBranch: "feature/test",
540 state: "open",
536 },541 },
537 }),542 }),
538 false,543 false,
...@@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => {...@@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => {
549 status({554 status({
550 hasUpstream: false,555 hasUpstream: false,
551 aheadCount: 1,556 aheadCount: 1,
552 openPr: {557 pr: {
553 number: 15,558 number: 15,
554 title: "Existing PR",559 title: "Existing PR",
555 url: "https://example.com/pr/15",560 url: "https://example.com/pr/15",
556 baseBranch: "main",561 baseBranch: "main",
557 headBranch: "feature/test",562 headBranch: "feature/test",
563 state: "open",
558 },564 },
559 }),565 }),
560 false,566 false,
...@@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => {...@@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => {
569575
570 it("buildMenuItems disables push and create PR when no commits are ahead", () => {576 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
571 const items = buildMenuItems(577 const items = buildMenuItems(
572 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),578 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
573 false,579 false,
574 );580 );
575 assert.deepEqual(items, [581 assert.deepEqual(items, [
apps/web/src/components/GitActionsControl.logic.ts+2-2
...@@ -108,7 +108,7 @@ export function buildMenuItems(...@@ -108,7 +108,7 @@ export function buildMenuItems(
108108
109 const hasBranch = gitStatus.branch !== null;109 const hasBranch = gitStatus.branch !== null;
110 const hasChanges = gitStatus.hasWorkingTreeChanges;110 const hasChanges = gitStatus.hasWorkingTreeChanges;
111 const hasOpenPr = gitStatus.openPr !== null;111 const hasOpenPr = gitStatus.pr?.state === "open";
112 const isBehind = gitStatus.behindCount > 0;112 const isBehind = gitStatus.behindCount > 0;
113 const canCommit = !isBusy && hasChanges;113 const canCommit = !isBusy && hasChanges;
114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
...@@ -178,7 +178,7 @@ export function resolveQuickAction(...@@ -178,7 +178,7 @@ export function resolveQuickAction(
178178
179 const hasBranch = gitStatus.branch !== null;179 const hasBranch = gitStatus.branch !== null;
180 const hasChanges = gitStatus.hasWorkingTreeChanges;180 const hasChanges = gitStatus.hasWorkingTreeChanges;
181 const hasOpenPr = gitStatus.openPr !== null;181 const hasOpenPr = gitStatus.pr?.state === "open";
182 const isAhead = gitStatus.aheadCount > 0;182 const isAhead = gitStatus.aheadCount > 0;
183 const isBehind = gitStatus.behindCount > 0;183 const isBehind = gitStatus.behindCount > 0;
184 const isDiverged = isAhead && isBehind;184 const isDiverged = isAhead && isBehind;
apps/web/src/components/GitActionsControl.tsx+11-6
...@@ -57,7 +57,7 @@ function getMenuActionDisabledReason(...@@ -57,7 +57,7 @@ function getMenuActionDisabledReason(
5757
58 const hasBranch = gitStatus.branch !== null;58 const hasBranch = gitStatus.branch !== null;
59 const hasChanges = gitStatus.hasWorkingTreeChanges;59 const hasChanges = gitStatus.hasWorkingTreeChanges;
60 const hasOpenPr = gitStatus.openPr !== null;60 const hasOpenPr = gitStatus.pr?.state === "open";
61 const isAhead = gitStatus.aheadCount > 0;61 const isAhead = gitStatus.aheadCount > 0;
62 const isBehind = gitStatus.behindCount > 0;62 const isBehind = gitStatus.behindCount > 0;
6363
...@@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
217 });217 });
218 return;218 return;
219 }219 }
220 const prUrl = gitStatusForActions?.openPr?.url ?? null;220 const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null;
221 if (!prUrl) {221 if (!prUrl) {
222 toastManager.add({222 toastManager.add({
223 type: "error",223 type: "error",
...@@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
237 }),237 }),
238 });238 });
239 void promise.catch(() => undefined);239 void promise.catch(() => undefined);
240 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);240 }, [api, gitStatusForActions?.pr, threadToastData]);
241241
242 const runGitActionWithToast = useCallback(242 const runGitActionWithToast = useCallback(
243 async ({243 async ({
...@@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
297 stopProgressUpdates();297 stopProgressUpdates();
298 const resultToast = summarizeGitResult(result);298 const resultToast = summarizeGitResult(result);
299299
300 const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url;300 const existingOpenPrUrl =
301 gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined;
302 const prUrl = result.pr.url ?? existingOpenPrUrl;
301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";303 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
302 const shouldOfferOpenPrCta =304 const shouldOfferOpenPrCta =
303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;305 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
...@@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
312 title: resultToast.title,314 title: resultToast.title,
313 description: resultToast.description,315 description: resultToast.description,
314 timeout: 0,316 timeout: 0,
315 data: threadToastData,317 data: {
318 ...threadToastData,
319 dismissAfterVisibleMs: 10_000,
320 },
316 ...(shouldOfferPushCta321 ...(shouldOfferPushCta
317 ? {322 ? {
318 actionProps: {323 actionProps: {
...@@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
362 api,367 api,
363 gitStatusForActions?.branch,368 gitStatusForActions?.branch,
364 gitStatusForActions?.hasWorkingTreeChanges,369 gitStatusForActions?.hasWorkingTreeChanges,
365 gitStatusForActions?.openPr?.url,370 gitStatusForActions?.pr,
366 isDefaultBranch,371 isDefaultBranch,
367 maybeConfirmPushToDefaultBranch,372 maybeConfirmPushToDefaultBranch,
368 runImmediateGitActionMutation,373 runImmediateGitActionMutation,
apps/web/src/components/Sidebar.tsx+140-32
...@@ -1,12 +1,20 @@...@@ -1,12 +1,20 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";1import {
2 GitPullRequestIcon,
3 MonitorIcon,
4 MoonIcon,
5 SunIcon,
6 TerminalIcon,
7} from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";8import { useCallback, useEffect, useMemo, useState } from "react";
3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";9import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
10import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
4import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";11import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
5import { isElectron } from "../env";12import { isElectron } from "../env";
6import { useTheme } from "../hooks/useTheme";13import { useTheme } from "../hooks/useTheme";
7import { DEFAULT_MODEL } from "../model-logic";14import { DEFAULT_MODEL } from "../model-logic";
8import { derivePendingApprovals } from "../session-logic";15import { derivePendingApprovals } from "../session-logic";
9import { useStore } from "../store";16import { useStore } from "../store";
17import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
10import {18import {
11 DEFAULT_THREAD_TERMINAL_HEIGHT,19 DEFAULT_THREAD_TERMINAL_HEIGHT,
12 DEFAULT_THREAD_TERMINAL_ID,20 DEFAULT_THREAD_TERMINAL_ID,
...@@ -15,9 +23,13 @@ import {...@@ -15,9 +23,13 @@ import {
15} from "../types";23} from "../types";
16import { useNativeApi } from "../hooks/useNativeApi";24import { useNativeApi } from "../hooks/useNativeApi";
17import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";25import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
26import { serverConfigQueryOptions } from "../lib/serverReactQuery";
27import { toastManager } from "./ui/toast";
18import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";28import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1929
20const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;30const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
31const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
32
21function formatRelativeTime(iso: string): string {33function formatRelativeTime(iso: string): string {
22 const diff = Date.now() - new Date(iso).getTime();34 const diff = Date.now() - new Date(iso).getTime();
23 const minutes = Math.floor(diff / 60_000);35 const minutes = Math.floor(diff / 60_000);
...@@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string {...@@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string {
36}48}
3749
38interface ThreadStatusPill {50interface ThreadStatusPill {
39 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged";51 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
40 colorClass: string;52 colorClass: string;
41 dotClass: string;53 dotClass: string;
42 pulse: boolean;54 pulse: boolean;
...@@ -48,6 +60,13 @@ interface TerminalStatusIndicator {...@@ -48,6 +60,13 @@ interface TerminalStatusIndicator {
48 pulse: boolean;60 pulse: boolean;
49}61}
5062
63interface PrStatusIndicator {
64 label: "PR open" | "PR closed" | "PR merged";
65 colorClass: string;
66}
67
68type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
69
51function hasUnseenCompletion(thread: Thread): boolean {70function hasUnseenCompletion(thread: Thread): boolean {
52 if (!thread.latestTurnCompletedAt) return false;71 if (!thread.latestTurnCompletedAt) return false;
53 const completedAt = Date.parse(thread.latestTurnCompletedAt);72 const completedAt = Date.parse(thread.latestTurnCompletedAt);
...@@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean {...@@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean {
62function threadStatusPill(81function threadStatusPill(
63 thread: Thread,82 thread: Thread,
64 hasPendingApprovals: boolean,83 hasPendingApprovals: boolean,
65 hasMergedPr: boolean,
66): ThreadStatusPill | null {84): ThreadStatusPill | null {
67 if (hasPendingApprovals) {85 if (hasPendingApprovals) {
68 return {86 return {
...@@ -91,15 +109,6 @@ function threadStatusPill(...@@ -91,15 +109,6 @@ function threadStatusPill(
91 };109 };
92 }110 }
93111
94 if (hasMergedPr) {
95 return {
96 label: "Merged",
97 colorClass: "text-violet-600 dark:text-violet-300/90",
98 dotClass: "bg-violet-500 dark:bg-violet-300/90",
99 pulse: false,
100 };
101 }
102
103 if (hasUnseenCompletion(thread)) {112 if (hasUnseenCompletion(thread)) {
104 return {113 return {
105 label: "Completed",114 label: "Completed",
...@@ -112,6 +121,28 @@ function threadStatusPill(...@@ -112,6 +121,28 @@ function threadStatusPill(
112 return null;121 return null;
113}122}
114123
124function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
125 if (prState === "open") {
126 return {
127 label: "PR open",
128 colorClass: "text-emerald-600 dark:text-emerald-300/90",
129 };
130 }
131 if (prState === "closed") {
132 return {
133 label: "PR closed",
134 colorClass: "text-zinc-500 dark:text-zinc-400/80",
135 };
136 }
137 if (prState === "merged") {
138 return {
139 label: "PR merged",
140 colorClass: "text-violet-600 dark:text-violet-300/90",
141 };
142 }
143 return null;
144}
145
115function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {146function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {
116 if (thread.runningTerminalIds.length === 0) {147 if (thread.runningTerminalIds.length === 0) {
117 return null;148 return null;
...@@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null...@@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
126export default function Sidebar() {157export default function Sidebar() {
127 const { state, dispatch } = useStore();158 const { state, dispatch } = useStore();
128 const api = useNativeApi();159 const api = useNativeApi();
160 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
161 ...serverConfigQueryOptions(api),
162 select: (config) => config.keybindings,
163 });
129 const queryClient = useQueryClient();164 const queryClient = useQueryClient();
130 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient }));165 const removeWorktreeMutation = useMutation(
166 gitRemoveWorktreeMutationOptions({ api, queryClient }),
167 );
131 const { theme, setTheme } = useTheme();168 const { theme, setTheme } = useTheme();
132 const [addingProject, setAddingProject] = useState(false);169 const [addingProject, setAddingProject] = useState(false);
133 const [newCwd, setNewCwd] = useState("");170 const [newCwd, setNewCwd] = useState("");
...@@ -176,7 +213,7 @@ export default function Sidebar() {...@@ -176,7 +213,7 @@ export default function Sidebar() {
176 };213 };
177 }),214 }),
178 });215 });
179 const mergedPrByThreadId = useMemo(() => {216 const prStateByThreadId = useMemo(() => {
180 const statusByCwd = new Map<string, GitStatusResult>();217 const statusByCwd = new Map<string, GitStatusResult>();
181 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {218 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
182 const cwd = threadGitStatusCwds[index];219 const cwd = threadGitStatusCwds[index];
...@@ -187,18 +224,24 @@ export default function Sidebar() {...@@ -187,18 +224,24 @@ export default function Sidebar() {
187 }224 }
188 }225 }
189226
190 const map = new Map<string, boolean>();227 const map = new Map<string, ThreadPrState>();
191 for (const target of threadGitTargets) {228 for (const target of threadGitTargets) {
192 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;229 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
193 const branchMatches =230 const branchMatches =
194 target.branch !== null && status?.branch !== null && status?.branch === target.branch;231 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
195 map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null);232 map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null);
196 }233 }
197 return map;234 return map;
198 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);235 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
199236
200 const handleNewThread = useCallback(237 const handleNewThread = useCallback(
201 (projectId: string) => {238 (
239 projectId: string,
240 options?: {
241 branch?: string | null;
242 worktreePath?: string | null;
243 },
244 ) => {
202 dispatch({245 dispatch({
203 type: "ADD_THREAD",246 type: "ADD_THREAD",
204 thread: {247 thread: {
...@@ -224,8 +267,8 @@ export default function Sidebar() {...@@ -224,8 +267,8 @@ export default function Sidebar() {
224 events: [],267 events: [],
225 error: null,268 error: null,
226 createdAt: new Date().toISOString(),269 createdAt: new Date().toISOString(),
227 branch: null,270 branch: options?.branch ?? null,
228 worktreePath: null,271 worktreePath: options?.worktreePath ?? null,
229 },272 },
230 });273 });
231 },274 },
...@@ -392,29 +435,77 @@ export default function Sidebar() {...@@ -392,29 +435,77 @@ export default function Sidebar() {
392 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],435 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
393 );436 );
394437
438 const handleProjectContextMenu = useCallback(
439 async (projectId: string, position: { x: number; y: number }) => {
440 if (!api) return;
441 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
442 if (clicked !== "delete") return;
443
444 const project = state.projects.find((entry) => entry.id === projectId);
445 if (!project) return;
446
447 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
448 if (projectThreads.length > 0) {
449 toastManager.add({
450 type: "warning",
451 title: "Project is not empty",
452 description: "Delete all threads in this project before deleting it.",
453 });
454 return;
455 }
456
457 const confirmed = await api.dialogs.confirm(
458 [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"),
459 );
460 if (!confirmed) return;
461
462 if (isElectron) {
463 try {
464 await api.projects.remove({ id: projectId });
465 } catch (error) {
466 const message =
467 error instanceof Error ? error.message : "Unknown error deleting project.";
468 console.error("Failed to remove project", { projectId, error });
469 toastManager.add({
470 type: "error",
471 title: `Failed to delete "${project.name}"`,
472 description: message,
473 });
474 return;
475 }
476 }
477
478 dispatch({ type: "DELETE_PROJECT", projectId });
479 },
480 [api, dispatch, state.projects, state.threads],
481 );
482
395 useEffect(() => {483 useEffect(() => {
396 const onWindowKeyDown = (event: KeyboardEvent) => {484 const onWindowKeyDown = (event: KeyboardEvent) => {
397 const isNewThreadShortcut =
398 event.metaKey &&
399 event.shiftKey &&
400 !event.ctrlKey &&
401 !event.altKey &&
402 event.key.toLowerCase() === "o";
403 if (!isNewThreadShortcut) return;
404
405 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);485 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
486 if (isChatNewLocalShortcut(event, keybindings)) {
487 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
488 if (!projectId) return;
489 event.preventDefault();
490 handleNewThread(projectId);
491 return;
492 }
493
494 if (!isChatNewShortcut(event, keybindings)) return;
406 const projectId = activeThread?.projectId ?? state.projects[0]?.id;495 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
407 if (!projectId) return;496 if (!projectId) return;
408
409 event.preventDefault();497 event.preventDefault();
410 handleNewThread(projectId);498 handleNewThread(projectId, {
499 branch: activeThread?.branch ?? null,
500 worktreePath: activeThread?.worktreePath ?? null,
501 });
411 };502 };
412503
413 window.addEventListener("keydown", onWindowKeyDown);504 window.addEventListener("keydown", onWindowKeyDown);
414 return () => {505 return () => {
415 window.removeEventListener("keydown", onWindowKeyDown);506 window.removeEventListener("keydown", onWindowKeyDown);
416 };507 };
417 }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);508 }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
418509
419 return (510 return (
420 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">511 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
...@@ -478,6 +569,13 @@ export default function Sidebar() {...@@ -478,6 +569,13 @@ export default function Sidebar() {
478 type="button"569 type="button"
479 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"570 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"
480 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}571 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
572 onContextMenu={(e) => {
573 e.preventDefault();
574 void handleProjectContextMenu(project.id, {
575 x: e.clientX,
576 y: e.clientY,
577 });
578 }}
481 >579 >
482 <span className="text-[10px] text-muted-foreground/70">580 <span className="text-[10px] text-muted-foreground/70">
483 {project.expanded ? "â–¼" : "â–¶"}581 {project.expanded ? "â–¼" : "â–¶"}
...@@ -496,9 +594,9 @@ export default function Sidebar() {...@@ -496,9 +594,9 @@ export default function Sidebar() {
496 const threadStatus = threadStatusPill(594 const threadStatus = threadStatusPill(
497 thread,595 thread,
498 pendingApprovalByThreadId.get(thread.id) === true,596 pendingApprovalByThreadId.get(thread.id) === true,
499 mergedPrByThreadId.get(thread.id) === true,
500 );597 );
501 const terminalStatus = terminalStatusIndicator(thread);598 const terminalStatus = terminalStatusIndicator(thread);
599 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);
502 return (600 return (
503 <button601 <button
504 key={thread.id}602 key={thread.id}
...@@ -538,6 +636,16 @@ export default function Sidebar() {...@@ -538,6 +636,16 @@ export default function Sidebar() {
538 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>636 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
539 </div>637 </div>
540 <div className="ml-2 flex shrink-0 items-center gap-1.5">638 <div className="ml-2 flex shrink-0 items-center gap-1.5">
639 {prStatus && (
640 <span
641 role="img"
642 aria-label={prStatus.label}
643 title={prStatus.label}
644 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
645 >
646 <GitPullRequestIcon className="size-3" />
647 </span>
648 )}
541 {terminalStatus && (649 {terminalStatus && (
542 <span650 <span
543 role="img"651 role="img"
apps/web/src/components/ThreadTerminalDrawer.tsx+65-36
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import { type NativeApi } from "@t3tools/contracts";3import { type NativeApi } from "@t3tools/contracts";
4import { Terminal, type ITheme } from "@xterm/xterm";4import { Terminal, type ITheme } from "@xterm/xterm";
5import {5import {
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 preferredTerminalEditor,18 preferredTerminalEditor,
19 resolvePathLinkTarget,19 resolvePathLinkTarget,
20} from "../terminal-links";20} from "../terminal-links";
21import { isTerminalClearShortcut } from "../terminal-shortcuts";21import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
22import {22import {
23 DEFAULT_THREAD_TERMINAL_HEIGHT,23 DEFAULT_THREAD_TERMINAL_HEIGHT,
24 DEFAULT_THREAD_TERMINAL_ID,24 DEFAULT_THREAD_TERMINAL_ID,
...@@ -152,24 +152,29 @@ function TerminalViewport({...@@ -152,24 +152,29 @@ function TerminalViewport({
152 terminalRef.current = terminal;152 terminalRef.current = terminal;
153 fitAddonRef.current = fitAddon;153 fitAddonRef.current = fitAddon;
154154
155 const sendClearShortcut = async () => {155 const sendTerminalInput = async (data: string, fallbackError: string) => {
156 const activeTerminal = terminalRef.current;156 const activeTerminal = terminalRef.current;
157 if (!activeTerminal) return;157 if (!activeTerminal) return;
158 try {158 try {
159 await api.terminal.write({ threadId, terminalId, data: "\u000c" });159 await api.terminal.write({ threadId, terminalId, data });
160 } catch (error) {160 } catch (error) {
161 writeSystemMessage(161 writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);
162 activeTerminal,
163 error instanceof Error ? error.message : "Failed to clear terminal",
164 );
165 }162 }
166 };163 };
167164
168 terminal.attachCustomKeyEventHandler((event) => {165 terminal.attachCustomKeyEventHandler((event) => {
166 const navigationData = terminalNavigationShortcutData(event);
167 if (navigationData !== null) {
168 event.preventDefault();
169 event.stopPropagation();
170 void sendTerminalInput(navigationData, "Failed to move cursor");
171 return false;
172 }
173
169 if (!isTerminalClearShortcut(event)) return true;174 if (!isTerminalClearShortcut(event)) return true;
170 event.preventDefault();175 event.preventDefault();
171 event.stopPropagation();176 event.stopPropagation();
172 void sendClearShortcut();177 void sendTerminalInput("\u000c", "Failed to clear terminal");
173 return false;178 return false;
174 });179 });
175180
...@@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps {...@@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps {
416 focusRequestId: number;421 focusRequestId: number;
417 onSplitTerminal: () => void;422 onSplitTerminal: () => void;
418 onNewTerminal: () => void;423 onNewTerminal: () => void;
424 splitShortcutLabel?: string | undefined;
425 newShortcutLabel?: string | undefined;
426 closeShortcutLabel?: string | undefined;
419 onActiveTerminalChange: (terminalId: string) => void;427 onActiveTerminalChange: (terminalId: string) => void;
420 onCloseTerminal: (terminalId: string) => void;428 onCloseTerminal: (terminalId: string) => void;
421 onHeightChange: (height: number) => void;429 onHeightChange: (height: number) => void;
...@@ -431,15 +439,9 @@ interface TerminalActionButtonProps {...@@ -431,15 +439,9 @@ interface TerminalActionButtonProps {
431function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {439function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
432 return (440 return (
433 <Popover>441 <Popover>
434 <PopoverTrigger openOnHover442 <PopoverTrigger
435 render={443 openOnHover
436 <button444 render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
437 type="button"
438 className={className}
439 onClick={onClick}
440 aria-label={label}
441 />
442 }
443 >445 >
444 {children}446 {children}
445 </PopoverTrigger>447 </PopoverTrigger>
...@@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({...@@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({
468 focusRequestId,470 focusRequestId,
469 onSplitTerminal,471 onSplitTerminal,
470 onNewTerminal,472 onNewTerminal,
473 splitShortcutLabel,
474 newShortcutLabel,
475 closeShortcutLabel,
471 onActiveTerminalChange,476 onActiveTerminalChange,
472 onCloseTerminal,477 onCloseTerminal,
473 onHeightChange,478 onHeightChange,
...@@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({...@@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({
583 ),588 ),
584 [normalizedTerminalIds],589 [normalizedTerminalIds],
585 );590 );
591 const closeTerminalActionLabel = closeShortcutLabel
592 ? `Close Terminal (${closeShortcutLabel})`
593 : "Close Terminal";
586594
587 useEffect(() => {595 useEffect(() => {
588 onHeightChangeRef.current = onHeightChange;596 onHeightChangeRef.current = onHeightChange;
...@@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({...@@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({
694 <TerminalActionButton702 <TerminalActionButton
695 className="p-1 text-foreground/90 transition-colors hover:bg-accent"703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
696 onClick={onSplitTerminal}704 onClick={onSplitTerminal}
697 label="Split Terminal"705 label={
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
707 }
698 >708 >
699 <SquareSplitHorizontal className="size-3.25" />709 <SquareSplitHorizontal className="size-3.25" />
700 </TerminalActionButton>710 </TerminalActionButton>
...@@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({...@@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({
702 <TerminalActionButton712 <TerminalActionButton
703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"713 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
704 onClick={onNewTerminal}714 onClick={onNewTerminal}
705 label="New Terminal"715 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
706 >716 >
707 <Plus className="size-3.25" />717 <Plus className="size-3.25" />
708 </TerminalActionButton>718 </TerminalActionButton>
...@@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({...@@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({
710 <TerminalActionButton720 <TerminalActionButton
711 className="p-1 text-foreground/90 transition-colors hover:bg-accent"721 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
712 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}722 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
713 label="Close Terminal"723 label={closeTerminalActionLabel}
714 >724 >
715 <Trash2 className="size-3.25" />725 <Trash2 className="size-3.25" />
716 </TerminalActionButton>726 </TerminalActionButton>
...@@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({...@@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({
779 <TerminalActionButton789 <TerminalActionButton
780 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"790 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
781 onClick={onSplitTerminal}791 onClick={onSplitTerminal}
782 label="Split Terminal"792 label={
793 splitShortcutLabel
794 ? `Split Terminal (${splitShortcutLabel})`
795 : "Split Terminal"
796 }
783 >797 >
784 <SquareSplitHorizontal className="size-3.25" />798 <SquareSplitHorizontal className="size-3.25" />
785 </TerminalActionButton>799 </TerminalActionButton>
786 <TerminalActionButton800 <TerminalActionButton
787 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"801 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
788 onClick={onNewTerminal}802 onClick={onNewTerminal}
789 label="New Terminal"803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
790 >804 >
791 <Plus className="size-3.25" />805 <Plus className="size-3.25" />
792 </TerminalActionButton>806 </TerminalActionButton>
793 <TerminalActionButton807 <TerminalActionButton
794 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"808 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
795 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}809 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
796 label="Close Terminal"810 label={closeTerminalActionLabel}
797 >811 >
798 <Trash2 className="size-3.25" />812 <Trash2 className="size-3.25" />
799 </TerminalActionButton>813 </TerminalActionButton>
...@@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({...@@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({
831 >845 >
832 {terminalGroup.terminalIds.map((terminalId) => {846 {terminalGroup.terminalIds.map((terminalId) => {
833 const isActive = terminalId === resolvedActiveTerminalId;847 const isActive = terminalId === resolvedActiveTerminalId;
848 const closeTerminalLabel = `Close ${
849 terminalLabelById.get(terminalId) ?? "terminal"
850 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
834 return (851 return (
835 <div852 <div
836 key={terminalId}853 key={terminalId}
...@@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({...@@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({
854 </span>871 </span>
855 </button>872 </button>
856 {normalizedTerminalIds.length > 1 && (873 {normalizedTerminalIds.length > 1 && (
857 <button874 <Popover>
858 type="button"875 <PopoverTrigger
859 className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"876 openOnHover
860 onClick={(event) => {877 render={
861 event.stopPropagation();878 <button
862 onCloseTerminal(terminalId);879 type="button"
863 }}880 className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
864 aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}881 onClick={() => onCloseTerminal(terminalId)}
865 title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}882 aria-label={closeTerminalLabel}
866 >883 />
867 ×884 }
868 </button>885 >
886 <XIcon className="size-2.5" />
887 </PopoverTrigger>
888 <PopoverPopup
889 tooltipStyle
890 side="bottom"
891 sideOffset={6}
892 align="center"
893 className="pointer-events-none select-none"
894 >
895 {closeTerminalLabel}
896 </PopoverPopup>
897 </Popover>
869 )}898 )}
870 </div>899 </div>
871 );900 );
apps/web/src/components/ui/toast.logic.test.ts created+16
...@@ -0,0 +1,16 @@
1import { assert, describe, it } from "vitest";
2import { shouldHideCollapsedToastContent } from "./toast.logic";
3
4describe("shouldHideCollapsedToastContent", () => {
5 it("keeps a single visible toast readable", () => {
6 assert.equal(shouldHideCollapsedToastContent(0, 1), false);
7 });
8
9 it("keeps the front-most toast readable in a visible stack", () => {
10 assert.equal(shouldHideCollapsedToastContent(0, 3), false);
11 });
12
13 it("hides non-front toasts until the stack is expanded", () => {
14 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
15 });
16});
apps/web/src/components/ui/toast.logic.ts created+9
...@@ -0,0 +1,9 @@
1export function shouldHideCollapsedToastContent(
2 visibleToastIndex: number,
3 visibleToastCount: number,
4): boolean {
5 // Keep the front-most toast readable even if Base UI marks it as "behind"
6 // due to toasts hidden by thread filtering.
7 if (visibleToastCount <= 1) return false;
8 return visibleToastIndex > 0;
9}
apps/web/src/components/ui/toast.tsx+190-84
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1"use client";1"use client";
22
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";
4import {5import {
5 CircleAlertIcon,6 CircleAlertIcon,
6 CircleCheckIcon,7 CircleCheckIcon,
...@@ -12,14 +13,18 @@ import {...@@ -12,14 +13,18 @@ import {
12import { cn } from "~/lib/utils";13import { cn } from "~/lib/utils";
13import { buttonVariants } from "~/components/ui/button";14import { buttonVariants } from "~/components/ui/button";
14import { useStore } from "~/store";15import { useStore } from "~/store";
16import { shouldHideCollapsedToastContent } from "./toast.logic";
1517
16type ThreadToastData = {18type ThreadToastData = {
17 threadId?: string | null;19 threadId?: string | null;
18 tooltipStyle?: boolean;20 tooltipStyle?: boolean;
21 dismissAfterVisibleMs?: number;
19};22};
2023
21const toastManager = Toast.createToastManager<ThreadToastData>();24const toastManager = Toast.createToastManager<ThreadToastData>();
22const anchoredToastManager = Toast.createToastManager<ThreadToastData>();25const anchoredToastManager = Toast.createToastManager<ThreadToastData>();
26type ToastId = ReturnType<typeof toastManager.add>;
27const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
2328
24const TOAST_ICONS = {29const TOAST_ICONS = {
25 error: CircleAlertIcon,30 error: CircleAlertIcon,
...@@ -50,6 +55,84 @@ function shouldRenderForActiveThread(...@@ -50,6 +55,84 @@ function shouldRenderForActiveThread(
50 return toastThreadId === activeThreadId;55 return toastThreadId === activeThreadId;
51}56}
5257
58function ThreadToastVisibleAutoDismiss({
59 toastId,
60 dismissAfterVisibleMs,
61}: {
62 toastId: ToastId;
63 dismissAfterVisibleMs: number | undefined;
64}) {
65 useEffect(() => {
66 if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return;
67 if (typeof window === "undefined" || typeof document === "undefined") return;
68
69 let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs;
70 let startedAtMs: number | null = null;
71 let timeoutId: number | null = null;
72 let closed = false;
73
74 const clearTimer = () => {
75 if (timeoutId === null) return;
76 window.clearTimeout(timeoutId);
77 timeoutId = null;
78 };
79
80 const closeToast = () => {
81 if (closed) return;
82 closed = true;
83 threadToastVisibleTimeoutRemainingMs.delete(toastId);
84 toastManager.close(toastId);
85 };
86
87 const pause = () => {
88 if (startedAtMs === null) return;
89 remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs));
90 startedAtMs = null;
91 clearTimer();
92 threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs);
93 };
94
95 const start = () => {
96 if (closed || startedAtMs !== null) return;
97 if (remainingMs <= 0) {
98 closeToast();
99 return;
100 }
101 startedAtMs = Date.now();
102 clearTimer();
103 timeoutId = window.setTimeout(() => {
104 remainingMs = 0;
105 startedAtMs = null;
106 closeToast();
107 }, remainingMs);
108 };
109
110 const syncTimer = () => {
111 const shouldRun = document.visibilityState === "visible" && document.hasFocus();
112 if (shouldRun) {
113 start();
114 return;
115 }
116 pause();
117 };
118
119 syncTimer();
120 document.addEventListener("visibilitychange", syncTimer);
121 window.addEventListener("focus", syncTimer);
122 window.addEventListener("blur", syncTimer);
123
124 return () => {
125 document.removeEventListener("visibilitychange", syncTimer);
126 window.removeEventListener("focus", syncTimer);
127 window.removeEventListener("blur", syncTimer);
128 pause();
129 clearTimer();
130 };
131 }, [dismissAfterVisibleMs, toastId]);
132
133 return null;
134}
135
53function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {136function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {
54 return (137 return (
55 <Toast.Provider toastManager={toastManager} {...props}>138 <Toast.Provider toastManager={toastManager} {...props}>
...@@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
64 const { toasts } = Toast.useToastManager<ThreadToastData>();147 const { toasts } = Toast.useToastManager<ThreadToastData>();
65 const activeThreadId = state.activeThreadId;148 const activeThreadId = state.activeThreadId;
66 const isTop = position.startsWith("top");149 const isTop = position.startsWith("top");
150 const visibleToasts = toasts.filter((toast) =>
151 shouldRenderForActiveThread(toast.data, activeThreadId),
152 );
153
154 useEffect(() => {
155 const activeToastIds = new Set(toasts.map((toast) => toast.id));
156 for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) {
157 if (!activeToastIds.has(toastId)) {
158 threadToastVisibleTimeoutRemainingMs.delete(toastId);
159 }
160 }
161 }, [toasts]);
67162
68 return (163 return (
69 <Toast.Portal data-slot="toast-portal">164 <Toast.Portal data-slot="toast-portal">
...@@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
81 data-position={position}176 data-position={position}
82 data-slot="toast-viewport"177 data-slot="toast-viewport"
83 >178 >
84 {toasts179 {visibleToasts.map((toast, visibleIndex) => {
85 .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId))180 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
86 .map((toast) => {181 const hideCollapsedContent = shouldHideCollapsedToastContent(
87 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;182 visibleIndex,
183 visibleToasts.length,
184 );
88185
89 return (186 return (
90 <Toast.Root187 <Toast.Root
188 className={cn(
189 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
190 // Base positioning using data-position
191 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
192 "data-[position*=left]:right-auto data-[position*=left]:left-0",
193 "data-[position*=center]:right-0 data-[position*=center]:left-0",
194 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
195 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
196 // Gap fill for hover
197 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
198 "data-[position*=top]:after:top-full",
199 "data-[position*=bottom]:after:bottom-full",
200 // Define some variables
201 "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
202 // Define offset-y variable
203 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
204 "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
205 // Default state transform
206 "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
207 "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
208 // Limited state
209 "data-limited:opacity-0",
210 // Expanded state
211 "data-expanded:h-(--toast-height)",
212 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
213 // Starting and ending animations
214 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
215 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
216 "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
217 "data-ending-style:opacity-0",
218 // Ending animations (direction-aware)
219 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
220 "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
221 "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
222 "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
223 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
224 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
225 // Ending animations (expanded)
226 "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
227 "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
228 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
229 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
230 )}
231 data-position={position}
232 key={toast.id}
233 swipeDirection={
234 position.includes("center")
235 ? [isTop ? "up" : "down"]
236 : position.includes("left")
237 ? ["left", isTop ? "up" : "down"]
238 : ["right", isTop ? "up" : "down"]
239 }
240 toast={toast}
241 >
242 <ThreadToastVisibleAutoDismiss
243 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
244 toastId={toast.id}
245 />
246 <Toast.Content
91 className={cn(247 className={cn(
92 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",248 "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100",
93 // Base positioning using data-position249 hideCollapsedContent &&
94 "data-[position*=right]:right-0 data-[position*=right]:left-auto",250 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
95 "data-[position*=left]:right-auto data-[position*=left]:left-0",
96 "data-[position*=center]:right-0 data-[position*=center]:left-0",
97 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
98 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
99 // Gap fill for hover
100 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
101 "data-[position*=top]:after:top-full",
102 "data-[position*=bottom]:after:bottom-full",
103 // Define some variables
104 "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
105 // Define offset-y variable
106 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
107 "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
108 // Default state transform
109 "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
110 "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
111 // Limited state
112 "data-limited:opacity-0",
113 // Expanded state
114 "data-expanded:h-(--toast-height)",
115 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
116 // Starting and ending animations
117 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
118 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
119 "data-ending-style:opacity-0",
120 // Ending animations (direction-aware)
121 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
122 "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
123 "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
124 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
125 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
126 // Ending animations (expanded)
127 "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
128 "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
129 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
130 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
131 )}251 )}
132 data-position={position}
133 key={toast.id}
134 swipeDirection={
135 position.includes("center")
136 ? [isTop ? "up" : "down"]
137 : position.includes("left")
138 ? ["left", isTop ? "up" : "down"]
139 : ["right", isTop ? "up" : "down"]
140 }
141 toast={toast}
142 >252 >
143 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">253 <div className="flex gap-2">
144 <div className="flex gap-2">254 {Icon && (
145 {Icon && (255 <div
146 <div256 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
147 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"257 data-slot="toast-icon"
148 data-slot="toast-icon"
149 >
150 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
151 </div>
152 )}
153
154 <div className="flex flex-col gap-0.5">
155 <Toast.Title className="font-medium" data-slot="toast-title" />
156 <Toast.Description
157 className="text-muted-foreground"
158 data-slot="toast-description"
159 />
160 </div>
161 </div>
162 {toast.actionProps && (
163 <Toast.Action
164 className={buttonVariants({ size: "xs" })}
165 data-slot="toast-action"
166 >258 >
167 {toast.actionProps.children}259 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
168 </Toast.Action>260 </div>
169 )}261 )}
170 </Toast.Content>262
171 </Toast.Root>263 <div className="flex flex-col gap-0.5">
172 );264 <Toast.Title className="font-medium" data-slot="toast-title" />
173 })}265 <Toast.Description
266 className="text-muted-foreground"
267 data-slot="toast-description"
268 />
269 </div>
270 </div>
271 {toast.actionProps && (
272 <Toast.Action className={buttonVariants({ size: "xs" })} data-slot="toast-action">
273 {toast.actionProps.children}
274 </Toast.Action>
275 )}
276 </Toast.Content>
277 </Toast.Root>
278 );
279 })}
174 </Toast.Viewport>280 </Toast.Viewport>
175 </Toast.Portal>281 </Toast.Portal>
176 );282 );
apps/web/src/keybindings.test.ts created+444
...@@ -0,0 +1,444 @@
1import { assert, describe, it } from "vitest";
2
3import {
4 type KeybindingCommand,
5 type KeybindingShortcut,
6 type KeybindingWhenNode,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import {
10 formatShortcutLabel,
11 isChatNewShortcut,
12 isChatNewLocalShortcut,
13 isOpenFavoriteEditorShortcut,
14 isTerminalClearShortcut,
15 isTerminalCloseShortcut,
16 isTerminalNewShortcut,
17 isTerminalSplitShortcut,
18 isTerminalToggleShortcut,
19 shortcutLabelForCommand,
20 terminalNavigationShortcutData,
21 type ShortcutEventLike,
22} from "./keybindings";
23
24function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
25 return {
26 key: "j",
27 metaKey: false,
28 ctrlKey: false,
29 shiftKey: false,
30 altKey: false,
31 ...overrides,
32 };
33}
34
35function modShortcut(
36 key: string,
37 overrides: Partial<Omit<KeybindingShortcut, "key">> = {},
38): KeybindingShortcut {
39 return {
40 key,
41 metaKey: false,
42 ctrlKey: false,
43 shiftKey: false,
44 altKey: false,
45 modKey: true,
46 ...overrides,
47 };
48}
49
50function whenIdentifier(name: string): KeybindingWhenNode {
51 return { type: "identifier", name };
52}
53
54function whenNot(node: KeybindingWhenNode): KeybindingWhenNode {
55 return { type: "not", node };
56}
57
58function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode {
59 return { type: "and", left, right };
60}
61
62interface TestBinding {
63 shortcut: KeybindingShortcut;
64 command: KeybindingCommand;
65 whenAst?: KeybindingWhenNode;
66}
67
68function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
69 return bindings.map((binding) => ({
70 command: binding.command,
71 shortcut: binding.shortcut,
72 ...(binding.whenAst ? { whenAst: binding.whenAst } : {}),
73 }));
74}
75
76const DEFAULT_BINDINGS = compile([
77 { shortcut: modShortcut("j"), command: "terminal.toggle" },
78 {
79 shortcut: modShortcut("d"),
80 command: "terminal.split",
81 whenAst: whenIdentifier("terminalFocus"),
82 },
83 {
84 shortcut: modShortcut("d", { shiftKey: true }),
85 command: "terminal.new",
86 whenAst: whenIdentifier("terminalFocus"),
87 },
88 {
89 shortcut: modShortcut("w"),
90 command: "terminal.close",
91 whenAst: whenIdentifier("terminalFocus"),
92 },
93 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
94 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
95 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
96]);
97
98describe("isTerminalToggleShortcut", () => {
99 it("matches Cmd+J on macOS", () => {
100 assert.isTrue(
101 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }),
102 );
103 });
104
105 it("matches Ctrl+J on non-macOS", () => {
106 assert.isTrue(
107 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }),
108 );
109 });
110});
111
112describe("split/new/close terminal shortcuts", () => {
113 it("requires terminalFocus for default split/new/close bindings", () => {
114 assert.isFalse(
115 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
116 platform: "MacIntel",
117 context: { terminalFocus: false },
118 }),
119 );
120 assert.isFalse(
121 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
122 platform: "Linux",
123 context: { terminalFocus: false },
124 }),
125 );
126 assert.isFalse(
127 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
128 platform: "Linux",
129 context: { terminalFocus: false },
130 }),
131 );
132 });
133
134 it("matches split/new when terminalFocus is true", () => {
135 assert.isTrue(
136 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
137 platform: "MacIntel",
138 context: { terminalFocus: true },
139 }),
140 );
141 assert.isTrue(
142 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
143 platform: "Linux",
144 context: { terminalFocus: true },
145 }),
146 );
147 assert.isTrue(
148 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
149 platform: "Linux",
150 context: { terminalFocus: true },
151 }),
152 );
153 });
154
155 it("supports when expressions", () => {
156 const keybindings = compile([
157 {
158 shortcut: modShortcut("\\"),
159 command: "terminal.split",
160 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
161 },
162 {
163 shortcut: modShortcut("n", { shiftKey: true }),
164 command: "terminal.new",
165 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
166 },
167 { shortcut: modShortcut("j"), command: "terminal.toggle" },
168 ]);
169 assert.isTrue(
170 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
171 platform: "Win32",
172 context: { terminalOpen: true, terminalFocus: false },
173 }),
174 );
175 assert.isFalse(
176 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
177 platform: "Win32",
178 context: { terminalOpen: false, terminalFocus: false },
179 }),
180 );
181 assert.isTrue(
182 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
183 platform: "Win32",
184 context: { terminalOpen: true, terminalFocus: false },
185 }),
186 );
187 });
188
189 it("supports when boolean literals", () => {
190 const keybindings = compile([
191 { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") },
192 { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") },
193 ]);
194
195 assert.isTrue(
196 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
197 platform: "Linux",
198 }),
199 );
200 assert.isFalse(
201 isTerminalNewShortcut(event({ key: "m", ctrlKey: true }), keybindings, {
202 platform: "Linux",
203 }),
204 );
205 });
206});
207
208describe("shortcutLabelForCommand", () => {
209 it("returns the most recent binding label", () => {
210 const bindings = compile([
211 {
212 shortcut: modShortcut("\\"),
213 command: "terminal.split",
214 whenAst: whenIdentifier("terminalFocus"),
215 },
216 {
217 shortcut: modShortcut("\\", { shiftKey: true }),
218 command: "terminal.split",
219 whenAst: whenNot(whenIdentifier("terminalFocus")),
220 },
221 ]);
222 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
223 });
224
225 it("returns labels for non-terminal commands", () => {
226 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
227 assert.strictEqual(
228 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
229 "Ctrl+O",
230 );
231 });
232});
233
234describe("chat/editor shortcuts", () => {
235 it("matches chat.new shortcut", () => {
236 assert.isTrue(
237 isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
238 platform: "MacIntel",
239 }),
240 );
241 assert.isTrue(
242 isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
243 platform: "Linux",
244 }),
245 );
246 });
247
248 it("matches chat.newLocal shortcut", () => {
249 assert.isTrue(
250 isChatNewLocalShortcut(
251 event({ key: "n", metaKey: true, shiftKey: true }),
252 DEFAULT_BINDINGS,
253 {
254 platform: "MacIntel",
255 },
256 ),
257 );
258 assert.isTrue(
259 isChatNewLocalShortcut(
260 event({ key: "n", ctrlKey: true, shiftKey: true }),
261 DEFAULT_BINDINGS,
262 {
263 platform: "Linux",
264 },
265 ),
266 );
267 });
268
269 it("matches editor.openFavorite shortcut", () => {
270 assert.isTrue(
271 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
272 platform: "MacIntel",
273 }),
274 );
275 assert.isTrue(
276 isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, {
277 platform: "Linux",
278 }),
279 );
280 });
281});
282
283describe("cross-command precedence", () => {
284 it("uses when + order so a later focused rule overrides a global rule", () => {
285 const keybindings = compile([
286 { shortcut: modShortcut("n"), command: "chat.new" },
287 {
288 shortcut: modShortcut("n"),
289 command: "terminal.new",
290 whenAst: whenIdentifier("terminalFocus"),
291 },
292 ]);
293
294 assert.isTrue(
295 isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
296 platform: "MacIntel",
297 context: { terminalFocus: true },
298 }),
299 );
300 assert.isFalse(
301 isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
302 platform: "MacIntel",
303 context: { terminalFocus: true },
304 }),
305 );
306 assert.isFalse(
307 isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
308 platform: "MacIntel",
309 context: { terminalFocus: false },
310 }),
311 );
312 assert.isTrue(
313 isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
314 platform: "MacIntel",
315 context: { terminalFocus: false },
316 }),
317 );
318 });
319
320 it("still lets a later global rule win when both rules match", () => {
321 const keybindings = compile([
322 {
323 shortcut: modShortcut("n"),
324 command: "terminal.new",
325 whenAst: whenIdentifier("terminalFocus"),
326 },
327 { shortcut: modShortcut("n"), command: "chat.new" },
328 ]);
329
330 assert.isFalse(
331 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
332 platform: "Linux",
333 context: { terminalFocus: true },
334 }),
335 );
336 assert.isTrue(
337 isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
338 platform: "Linux",
339 context: { terminalFocus: true },
340 }),
341 );
342 });
343});
344
345describe("formatShortcutLabel", () => {
346 it("formats labels for macOS", () => {
347 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D");
348 });
349
350 it("formats labels for non-macOS", () => {
351 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D");
352 });
353
354 it("formats labels for plus key", () => {
355 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+");
356 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++");
357 });
358});
359
360describe("isTerminalClearShortcut", () => {
361 it("matches Ctrl+L on all platforms", () => {
362 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux"));
363 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel"));
364 });
365
366 it("matches Cmd+K on macOS", () => {
367 assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel"));
368 });
369
370 it("ignores non-keydown events", () => {
371 assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux"));
372 });
373});
374
375describe("terminalNavigationShortcutData", () => {
376 it("maps Option+Arrow on macOS to word movement", () => {
377 assert.strictEqual(
378 terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"),
379 "\u001bb",
380 );
381 assert.strictEqual(
382 terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"),
383 "\u001bf",
384 );
385 });
386
387 it("maps Cmd+Arrow on macOS to line movement", () => {
388 assert.strictEqual(
389 terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"),
390 "\u0001",
391 );
392 assert.strictEqual(
393 terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"),
394 "\u0005",
395 );
396 });
397
398 it("maps Ctrl+Arrow on non-macOS to word movement", () => {
399 assert.strictEqual(
400 terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"),
401 "\u001bb",
402 );
403 assert.strictEqual(
404 terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"),
405 "\u001bf",
406 );
407 });
408
409 it("rejects unsupported combinations", () => {
410 assert.isNull(
411 terminalNavigationShortcutData(
412 event({ key: "ArrowLeft", shiftKey: true, altKey: true }),
413 "MacIntel",
414 ),
415 );
416 assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux"));
417 assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel"));
418 });
419
420 it("ignores non-keydown events", () => {
421 assert.isNull(
422 terminalNavigationShortcutData(
423 event({ type: "keyup", key: "ArrowLeft", altKey: true }),
424 "MacIntel",
425 ),
426 );
427 });
428});
429
430describe("plus key parsing", () => {
431 it("matches the plus key shortcut", () => {
432 const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]);
433 assert.isTrue(
434 isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, {
435 platform: "MacIntel",
436 }),
437 );
438 assert.isTrue(
439 isTerminalToggleShortcut(event({ key: "+", ctrlKey: true }), plusBindings, {
440 platform: "Linux",
441 }),
442 );
443 });
444});
apps/web/src/keybindings.ts created+286
...@@ -0,0 +1,286 @@
1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
7import { isMacPlatform } from "./lib/utils";
8
9export interface ShortcutEventLike {
10 type?: string;
11 key: string;
12 metaKey: boolean;
13 ctrlKey: boolean;
14 shiftKey: boolean;
15 altKey: boolean;
16}
17
18export interface ShortcutMatchContext {
19 terminalFocus: boolean;
20 terminalOpen: boolean;
21 [key: string]: boolean;
22}
23
24interface ShortcutMatchOptions {
25 platform?: string;
26 context?: Partial<ShortcutMatchContext>;
27}
28
29const TERMINAL_WORD_BACKWARD = "\u001bb";
30const TERMINAL_WORD_FORWARD = "\u001bf";
31const TERMINAL_LINE_START = "\u0001";
32const TERMINAL_LINE_END = "\u0005";
33
34function normalizeEventKey(key: string): string {
35 const normalized = key.toLowerCase();
36 if (normalized === "esc") return "escape";
37 return normalized;
38}
39
40function matchesShortcut(
41 event: ShortcutEventLike,
42 shortcut: KeybindingShortcut,
43 platform = navigator.platform,
44): boolean {
45 const key = normalizeEventKey(event.key);
46 if (key !== shortcut.key) return false;
47
48 const useMetaForMod = isMacPlatform(platform);
49 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
50 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
51 return (
52 event.metaKey === expectedMeta &&
53 event.ctrlKey === expectedCtrl &&
54 event.shiftKey === shortcut.shiftKey &&
55 event.altKey === shortcut.altKey
56 );
57}
58
59function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
60 return options?.platform ?? navigator.platform;
61}
62
63function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
64 return {
65 terminalFocus: false,
66 terminalOpen: false,
67 ...options?.context,
68 };
69}
70
71function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean {
72 switch (node.type) {
73 case "identifier":
74 if (node.name === "true") return true;
75 if (node.name === "false") return false;
76 return Boolean(context[node.name]);
77 case "not":
78 return !evaluateWhenNode(node.node, context);
79 case "and":
80 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
81 case "or":
82 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
83 }
84}
85
86function matchesWhenClause(
87 whenAst: KeybindingWhenNode | undefined,
88 context: ShortcutMatchContext,
89): boolean {
90 if (!whenAst) return true;
91 return evaluateWhenNode(whenAst, context);
92}
93
94function matchesCommandShortcut(
95 event: ShortcutEventLike,
96 keybindings: ResolvedKeybindingsConfig,
97 command: KeybindingCommand,
98 options?: ShortcutMatchOptions,
99): boolean {
100 return resolveShortcutCommand(event, keybindings, options) === command;
101}
102
103function resolveShortcutCommand(
104 event: ShortcutEventLike,
105 keybindings: ResolvedKeybindingsConfig,
106 options?: ShortcutMatchOptions,
107): KeybindingCommand | null {
108 const platform = resolvePlatform(options);
109 const context = resolveContext(options);
110
111 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
112 const binding = keybindings[index];
113 if (!binding) continue;
114 if (!matchesWhenClause(binding.whenAst, context)) continue;
115 if (!matchesShortcut(event, binding.shortcut, platform)) continue;
116 return binding.command;
117 }
118 return null;
119}
120
121function formatShortcutKeyLabel(key: string): string {
122 if (key === " ") return "Space";
123 if (key.length === 1) return key.toUpperCase();
124 if (key === "escape") return "Esc";
125 if (key === "arrowup") return "Up";
126 if (key === "arrowdown") return "Down";
127 if (key === "arrowleft") return "Left";
128 if (key === "arrowright") return "Right";
129 return key.slice(0, 1).toUpperCase() + key.slice(1);
130}
131
132export function formatShortcutLabel(
133 shortcut: KeybindingShortcut,
134 platform = navigator.platform,
135): string {
136 const keyLabel = formatShortcutKeyLabel(shortcut.key);
137 const useMetaForMod = isMacPlatform(platform);
138 const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
139 const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
140 const showAlt = shortcut.altKey;
141 const showShift = shortcut.shiftKey;
142
143 if (useMetaForMod) {
144 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
145 }
146
147 const parts: string[] = [];
148 if (showCtrl) parts.push("Ctrl");
149 if (showAlt) parts.push("Alt");
150 if (showShift) parts.push("Shift");
151 if (showMeta) parts.push("Meta");
152 parts.push(keyLabel);
153 return parts.join("+");
154}
155
156export function shortcutLabelForCommand(
157 keybindings: ResolvedKeybindingsConfig,
158 command: KeybindingCommand,
159 platform = navigator.platform,
160): string | null {
161 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
162 const binding = keybindings[index];
163 if (!binding || binding.command !== command) continue;
164 return formatShortcutLabel(binding.shortcut, platform);
165 }
166 return null;
167}
168
169export function isTerminalToggleShortcut(
170 event: ShortcutEventLike,
171 keybindings: ResolvedKeybindingsConfig,
172 options?: ShortcutMatchOptions,
173): boolean {
174 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
175}
176
177export function isTerminalSplitShortcut(
178 event: ShortcutEventLike,
179 keybindings: ResolvedKeybindingsConfig,
180 options?: ShortcutMatchOptions,
181): boolean {
182 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
183}
184
185export function isTerminalNewShortcut(
186 event: ShortcutEventLike,
187 keybindings: ResolvedKeybindingsConfig,
188 options?: ShortcutMatchOptions,
189): boolean {
190 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
191}
192
193export function isTerminalCloseShortcut(
194 event: ShortcutEventLike,
195 keybindings: ResolvedKeybindingsConfig,
196 options?: ShortcutMatchOptions,
197): boolean {
198 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
199}
200
201export function isChatNewShortcut(
202 event: ShortcutEventLike,
203 keybindings: ResolvedKeybindingsConfig,
204 options?: ShortcutMatchOptions,
205): boolean {
206 return matchesCommandShortcut(event, keybindings, "chat.new", options);
207}
208
209export function isChatNewLocalShortcut(
210 event: ShortcutEventLike,
211 keybindings: ResolvedKeybindingsConfig,
212 options?: ShortcutMatchOptions,
213): boolean {
214 return matchesCommandShortcut(event, keybindings, "chat.newLocal", options);
215}
216
217export function isOpenFavoriteEditorShortcut(
218 event: ShortcutEventLike,
219 keybindings: ResolvedKeybindingsConfig,
220 options?: ShortcutMatchOptions,
221): boolean {
222 return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
223}
224
225export function isTerminalClearShortcut(
226 event: ShortcutEventLike,
227 platform = navigator.platform,
228): boolean {
229 if (event.type !== undefined && event.type !== "keydown") {
230 return false;
231 }
232
233 const key = event.key.toLowerCase();
234
235 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
236 return true;
237 }
238
239 return (
240 isMacPlatform(platform) &&
241 key === "k" &&
242 event.metaKey &&
243 !event.ctrlKey &&
244 !event.altKey &&
245 !event.shiftKey
246 );
247}
248
249export function terminalNavigationShortcutData(
250 event: ShortcutEventLike,
251 platform = navigator.platform,
252): string | null {
253 if (event.type !== undefined && event.type !== "keydown") {
254 return null;
255 }
256
257 if (event.shiftKey) return null;
258
259 const key = normalizeEventKey(event.key);
260 if (key !== "arrowleft" && key !== "arrowright") {
261 return null;
262 }
263
264 const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD;
265 const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END;
266
267 if (isMacPlatform(platform)) {
268 if (event.altKey && !event.metaKey && !event.ctrlKey) {
269 return moveWord;
270 }
271 if (event.metaKey && !event.altKey && !event.ctrlKey) {
272 return moveLine;
273 }
274 return null;
275 }
276
277 if (event.ctrlKey && !event.metaKey && !event.altKey) {
278 return moveWord;
279 }
280
281 if (event.altKey && !event.metaKey && !event.ctrlKey) {
282 return moveWord;
283 }
284
285 return null;
286}
apps/web/src/lib/serverReactQuery.ts created+21
...@@ -0,0 +1,21 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export const serverQueryKeys = {
5 all: ["server"] as const,
6 config: () => ["server", "config"] as const,
7};
8
9export function serverConfigQueryOptions(api: NativeApi | undefined) {
10 return queryOptions({
11 queryKey: serverQueryKeys.config(),
12 queryFn: async () => {
13 if (!api || !api.server || typeof api.server.getConfig !== "function") {
14 throw new Error("Server config is unavailable.");
15 }
16 return api.server.getConfig();
17 },
18 enabled: !!api && !!api.server && typeof api.server.getConfig === "function",
19 staleTime: Infinity,
20 });
21}
apps/web/src/lib/utils.test.ts created+15
...@@ -0,0 +1,15 @@
1import { assert, describe, it } from "vitest";
2
3import { isWindowsPlatform } from "./utils";
4
5describe("isWindowsPlatform", () => {
6 it("matches Windows platform identifiers", () => {
7 assert.isTrue(isWindowsPlatform("Win32"));
8 assert.isTrue(isWindowsPlatform("Windows"));
9 assert.isTrue(isWindowsPlatform("windows_nt"));
10 });
11
12 it("does not match darwin", () => {
13 assert.isFalse(isWindowsPlatform("darwin"));
14 });
15});
apps/web/src/lib/utils.ts+8
...@@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge";...@@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge";
4export function cn(...inputs: CxOptions) {4export function cn(...inputs: CxOptions) {
5 return twMerge(cx(inputs));5 return twMerge(cx(inputs));
6}6}
7
8export function isMacPlatform(platform: string): boolean {
9 return /mac|iphone|ipad|ipod/i.test(platform);
10}
11
12export function isWindowsPlatform(platform: string): boolean {
13 return /^win(dows)?/i.test(platform);
14}
apps/web/src/store.test.ts+45
...@@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => {...@@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => {
486 expect(next.activeThreadId).toBe("thread-a");486 expect(next.activeThreadId).toBe("thread-a");
487 });487 });
488488
489 it("deletes a project and all of its threads", () => {
490 const state: AppState = {
491 projects: [
492 {
493 id: "project-1",
494 name: "One",
495 cwd: "/tmp/one",
496 model: "gpt-5.3-codex",
497 expanded: true,
498 },
499 {
500 id: "project-2",
501 name: "Two",
502 cwd: "/tmp/two",
503 model: "gpt-5.3-codex",
504 expanded: true,
505 },
506 ],
507 threads: [
508 makeThread({
509 id: "thread-a",
510 projectId: "project-1",
511 }),
512 makeThread({
513 id: "thread-b",
514 projectId: "project-2",
515 }),
516 ],
517 activeThreadId: "thread-a",
518 runtimeMode: "full-access",
519 diffOpen: false,
520 };
521
522 const next = reducer(state, {
523 type: "DELETE_PROJECT",
524 projectId: "project-1",
525 });
526
527 expect(next.projects).toHaveLength(1);
528 expect(next.projects[0]?.id).toBe("project-2");
529 expect(next.threads).toHaveLength(1);
530 expect(next.threads[0]?.id).toBe("thread-b");
531 expect(next.activeThreadId).toBe("thread-b");
532 });
533
489 it("marks the active thread as visited when selected", () => {534 it("marks the active thread as visited when selected", () => {
490 const state = makeState(535 const state = makeState(
491 makeThread({536 makeThread({
apps/web/src/store.ts+20
...@@ -28,6 +28,7 @@ type Action =...@@ -28,6 +28,7 @@ type Action =
28 | { type: "ADD_PROJECT"; project: Project }28 | { type: "ADD_PROJECT"; project: Project }
29 | { type: "SYNC_PROJECTS"; projects: Project[] }29 | { type: "SYNC_PROJECTS"; projects: Project[] }
30 | { type: "TOGGLE_PROJECT"; projectId: string }30 | { type: "TOGGLE_PROJECT"; projectId: string }
31 | { type: "DELETE_PROJECT"; projectId: string }
31 | { type: "ADD_THREAD"; thread: Thread }32 | { type: "ADD_THREAD"; thread: Thread }
32 | { type: "SET_ACTIVE_THREAD"; threadId: string }33 | { type: "SET_ACTIVE_THREAD"; threadId: string }
33 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }34 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
...@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState {
473 ),474 ),
474 };475 };
475476
477 case "DELETE_PROJECT": {
478 const projects = state.projects.filter((project) => project.id !== action.projectId);
479 if (projects.length === state.projects.length) {
480 return state;
481 }
482
483 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
484 const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
485 ? state.activeThreadId
486 : (threads[0]?.id ?? null);
487
488 return {
489 ...state,
490 projects,
491 threads,
492 activeThreadId,
493 };
494 }
495
476 case "ADD_THREAD": {496 case "ADD_THREAD": {
477 const nextThread = normalizeThreadTerminals({497 const nextThread = normalizeThreadTerminals({
478 ...action.thread,498 ...action.thread,
apps/web/src/terminal-links.ts+1-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { EDITORS, type EditorId } from "@t3tools/contracts";1import { EDITORS, type EditorId } from "@t3tools/contracts";
2import { isMacPlatform } from "./lib/utils";
23
3export type TerminalLinkKind = "url" | "path";4export type TerminalLinkKind = "url" | "path";
45
...@@ -15,10 +16,6 @@ const FILE_PATH_PATTERN =...@@ -15,10 +16,6 @@ const FILE_PATH_PATTERN =
15const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;16const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
16const LAST_EDITOR_KEY = "t3code:last-editor";17const LAST_EDITOR_KEY = "t3code:last-editor";
1718
18function isMacPlatform(platform: string): boolean {
19 return /mac|iphone|ipad|ipod/i.test(platform);
20}
21
22function trimClosingDelimiters(value: string): string {19function trimClosingDelimiters(value: string): string {
23 let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");20 let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
24 if (output.length === 0) return output;21 if (output.length === 0) return output;
apps/web/src/terminal-shortcuts.test.ts deleted-63
...@@ -1,63 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 isTerminalClearShortcut,
5 isTerminalToggleShortcut,
6 type ShortcutEventLike,
7} from "./terminal-shortcuts";
8
9function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
10 return {
11 key: "j",
12 metaKey: false,
13 ctrlKey: false,
14 shiftKey: false,
15 altKey: false,
16 ...overrides,
17 };
18}
19
20describe("isTerminalToggleShortcut", () => {
21 it("matches Cmd+J on macOS", () => {
22 expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
23 });
24
25 it("matches Ctrl+J on non-macOS", () => {
26 expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true);
27 });
28
29 it("rejects wrong modifiers", () => {
30 expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false);
31 expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe(
32 false,
33 );
34 });
35
36 it("rejects non-j keys", () => {
37 expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false);
38 });
39});
40
41describe("isTerminalClearShortcut", () => {
42 it("matches Ctrl+L on all platforms", () => {
43 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true);
44 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true);
45 });
46
47 it("matches Cmd+K on macOS", () => {
48 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true);
49 });
50
51 it("rejects Cmd+K on non-macOS", () => {
52 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false);
53 });
54
55 it("rejects wrong modifiers", () => {
56 expect(
57 isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"),
58 ).toBe(false);
59 expect(
60 isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"),
61 ).toBe(false);
62 });
63});
apps/web/src/terminal-shortcuts.ts deleted-46
...@@ -1,46 +0,0 @@
1export interface ShortcutEventLike {
2 key: string;
3 metaKey: boolean;
4 ctrlKey: boolean;
5 shiftKey: boolean;
6 altKey: boolean;
7}
8
9function isMacPlatform(platform: string): boolean {
10 return /mac|iphone|ipad|ipod/i.test(platform);
11}
12
13export function isTerminalToggleShortcut(
14 event: ShortcutEventLike,
15 platform = navigator.platform,
16): boolean {
17 const key = event.key.toLowerCase();
18 if (key !== "j") return false;
19 if (event.shiftKey || event.altKey) return false;
20
21 if (isMacPlatform(platform)) {
22 return event.metaKey && !event.ctrlKey;
23 }
24
25 return event.ctrlKey && !event.metaKey;
26}
27
28export function isTerminalClearShortcut(
29 event: ShortcutEventLike,
30 platform = navigator.platform,
31): boolean {
32 const key = event.key.toLowerCase();
33
34 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
35 return true;
36 }
37
38 return (
39 isMacPlatform(platform) &&
40 key === "k" &&
41 event.metaKey &&
42 !event.ctrlKey &&
43 !event.altKey &&
44 !event.shiftKey
45 );
46}
apps/web/src/wsNativeApi.ts+3
...@@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi {...@@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi {
139 return showContextMenuFallback(items, position);139 return showContextMenuFallback(items, position);
140 },140 },
141 },141 },
142 server: {
143 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
144 },
142 };145 };
143146
144 instance = { api, transport };147 instance = { api, transport };
packages/contracts/src/index.ts+2
...@@ -5,5 +5,7 @@ export * from "./terminal";...@@ -5,5 +5,7 @@ export * from "./terminal";
5export * from "./provider";5export * from "./provider";
6export * from "./model";6export * from "./model";
7export * from "./ws";7export * from "./ws";
8export * from "./keybindings";
9export * from "./server";
8export * from "./project";10export * from "./project";
9export * from "./git";11export * from "./git";
packages/contracts/src/ipc.ts+4
...@@ -31,6 +31,7 @@ import type {...@@ -31,6 +31,7 @@ import type {
31 ProjectListResult,31 ProjectListResult,
32 ProjectRemoveInput,32 ProjectRemoveInput,
33} from "./project";33} from "./project";
34import type { ServerConfig } from "./server";
34import type {35import type {
35 TerminalClearInput,36 TerminalClearInput,
36 TerminalCloseInput,37 TerminalCloseInput,
...@@ -113,4 +114,7 @@ export interface NativeApi {...@@ -113,4 +114,7 @@ export interface NativeApi {
113 position?: { x: number; y: number },114 position?: { x: number; y: number },
114 ) => Promise<T | null>;115 ) => Promise<T | null>;
115 };116 };
117 server: {
118 getConfig: () => Promise<ServerConfig>;
119 };
116}120}
packages/contracts/src/keybindings.test.ts created+104
...@@ -0,0 +1,104 @@
1import { assert, describe, it } from "vitest";
2
3import {
4 keybindingRuleSchema,
5 keybindingsConfigSchema,
6 resolvedKeybindingRuleSchema,
7 resolvedKeybindingsConfigSchema,
8} from "./keybindings";
9
10describe("keybindings contracts", () => {
11 it("parses keybinding rules", () => {
12 const parsed = keybindingRuleSchema.parse({
13 key: "mod+j",
14 command: "terminal.toggle",
15 });
16 assert.strictEqual(parsed.command, "terminal.toggle");
17
18 const parsedClose = keybindingRuleSchema.parse({
19 key: "mod+w",
20 command: "terminal.close",
21 });
22 assert.strictEqual(parsedClose.command, "terminal.close");
23
24 const parsedLocal = keybindingRuleSchema.parse({
25 key: "mod+shift+n",
26 command: "chat.newLocal",
27 });
28 assert.strictEqual(parsedLocal.command, "chat.newLocal");
29 });
30
31 it("rejects invalid command values", () => {
32 assert.throws(() =>
33 keybindingRuleSchema.parse({
34 key: "mod+j",
35 command: "invalid.command",
36 }),
37 );
38 });
39
40 it("parses keybindings array payload", () => {
41 const parsed = keybindingsConfigSchema.parse([
42 { key: "mod+j", command: "terminal.toggle" },
43 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
44 ]);
45 assert.lengthOf(parsed, 2);
46 });
47
48 it("parses resolved keybinding rules", () => {
49 const parsed = resolvedKeybindingRuleSchema.parse({
50 command: "terminal.split",
51 shortcut: {
52 key: "d",
53 metaKey: false,
54 ctrlKey: false,
55 shiftKey: false,
56 altKey: false,
57 modKey: true,
58 },
59 whenAst: {
60 type: "and",
61 left: { type: "identifier", name: "terminalOpen" },
62 right: {
63 type: "not",
64 node: { type: "identifier", name: "terminalFocus" },
65 },
66 },
67 });
68 assert.strictEqual(parsed.shortcut.key, "d");
69 });
70
71 it("parses resolved keybindings arrays", () => {
72 const parsed = resolvedKeybindingsConfigSchema.parse([
73 {
74 command: "terminal.toggle",
75 shortcut: {
76 key: "j",
77 metaKey: false,
78 ctrlKey: false,
79 shiftKey: false,
80 altKey: false,
81 modKey: true,
82 },
83 },
84 ]);
85 assert.lengthOf(parsed, 1);
86 });
87
88 it("rejects unknown fields in resolved keybinding rules", () => {
89 assert.throws(() =>
90 resolvedKeybindingRuleSchema.parse({
91 command: "terminal.toggle",
92 shortcut: {
93 key: "j",
94 metaKey: false,
95 ctrlKey: false,
96 shiftKey: false,
97 altKey: false,
98 modKey: true,
99 },
100 key: "mod+j",
101 }),
102 );
103 });
104});
packages/contracts/src/keybindings.ts created+75
...@@ -0,0 +1,75 @@
1import { z } from "zod";
2
3export const keybindingCommandSchema = z.enum([
4 "terminal.toggle",
5 "terminal.split",
6 "terminal.new",
7 "terminal.close",
8 "chat.new",
9 "chat.newLocal",
10 "editor.openFavorite",
11]);
12
13const keybindingValueSchema = z.string().trim().min(1).max(64);
14const keybindingWhenSchema = z.string().trim().min(1).max(256);
15
16export const keybindingRuleSchema = z.object({
17 key: keybindingValueSchema,
18 command: keybindingCommandSchema,
19 when: keybindingWhenSchema.optional(),
20});
21
22export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
23
24export const keybindingShortcutSchema = z.object({
25 key: z.string().trim().min(1).max(32),
26 metaKey: z.boolean(),
27 ctrlKey: z.boolean(),
28 shiftKey: z.boolean(),
29 altKey: z.boolean(),
30 modKey: z.boolean(),
31}).strict();
32
33export type KeybindingWhenNode =
34 | { type: "identifier"; name: string }
35 | { type: "not"; node: KeybindingWhenNode }
36 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
37 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
38
39export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>
40 z.union([
41 z.object({
42 type: z.literal("identifier"),
43 name: z.string().min(1),
44 }),
45 z.object({
46 type: z.literal("not"),
47 node: keybindingWhenNodeSchema,
48 }),
49 z.object({
50 type: z.literal("and"),
51 left: keybindingWhenNodeSchema,
52 right: keybindingWhenNodeSchema,
53 }),
54 z.object({
55 type: z.literal("or"),
56 left: keybindingWhenNodeSchema,
57 right: keybindingWhenNodeSchema,
58 }),
59 ]),
60);
61
62export const resolvedKeybindingRuleSchema = z.object({
63 command: keybindingCommandSchema,
64 shortcut: keybindingShortcutSchema,
65 whenAst: keybindingWhenNodeSchema.optional(),
66}).strict();
67
68export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
69
70export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
71export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
72export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
73export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>;
74export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>;
75export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>;
packages/contracts/src/server.ts created+9
...@@ -0,0 +1,9 @@
1import { z } from "zod";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";
3
4export const serverConfigSchema = z.object({
5 cwd: z.string().min(1),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),
7});
8
9export type ServerConfig = z.infer<typeof serverConfigSchema>;