querysub 0.442.0 → 0.446.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,16 @@
2
2
  "permissions": {
3
3
  "allow": [
4
4
  "Bash(Get-ChildItem *)",
5
- "mcp__querysub-logs__searchIndexes"
5
+ "mcp__querysub-logs__searchIndexes",
6
+ "mcp__node-debugger__listNodes",
7
+ "mcp__node-debugger__attach",
8
+ "mcp__node-debugger__evaluate",
9
+ "mcp__node-debugger__attachNode",
10
+ "mcp__node-debugger__getSource",
11
+ "mcp__node-debugger__logpoint",
12
+ "Bash(claude mcp *)",
13
+ "mcp__querysub-logs__search",
14
+ "Bash(yarn type *)"
6
15
  ]
7
16
  }
8
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.442.0",
3
+ "version": "0.446.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -21,7 +21,9 @@
21
21
  "audit-imports": "yarn typenode ./src/diagnostics/auditImportViolations.ts",
22
22
  "test": "yarn typenode ./test.ts",
23
23
  "mcp": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts",
24
- "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/"
24
+ "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
25
+ "mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
26
+ "mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/"
25
27
  },
26
28
  "bin": {
27
29
  "deploy": "./bin/deploy.js",
@@ -396,7 +396,11 @@ export class ArchivesBackblaze {
396
396
  public?: boolean;
397
397
  immutable?: boolean;
398
398
  cacheTime?: number;
399
- }) { }
399
+ allowedOrigins?: string[];
400
+ }) {
401
+ // Get the api, to setup cors
402
+ void this.getBucketAPI();
403
+ }
400
404
 
401
405
  private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
402
406
  private bucketId = "";
@@ -425,7 +429,7 @@ export class ArchivesBackblaze {
425
429
  // ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
426
430
  let desiredCorsRules = [{
427
431
  corsRuleName: "allowAll",
428
- allowedOrigins: ["https"],
432
+ allowedOrigins: this.config.allowedOrigins ?? ["https"],
429
433
  allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
430
434
  allowedHeaders: ["range"],
431
435
  exposeHeaders: ["x-bz-content-sha1"],
@@ -1024,7 +1028,8 @@ export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
1024
1028
  return new ArchivesBackblaze({
1025
1029
  bucketName: domain + "-public-immutable",
1026
1030
  public: true,
1027
- immutable: true
1031
+ immutable: true,
1032
+ allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
1028
1033
  });
1029
1034
  });
1030
1035
 
@@ -1036,5 +1041,6 @@ export const getArchivesBackblazePublic = cache((domain: string) => {
1036
1041
  bucketName: domain + "-public",
1037
1042
  public: true,
1038
1043
  cacheTime: timeInMinute,
1044
+ allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
1039
1045
  });
1040
1046
  });
@@ -36,10 +36,11 @@ export type AuthoritySpec = {
36
36
  // If the path.startsWith(prefix), but prefix !== path, then we hash getPathIndex(path, hashIndex)
37
37
  // - For now, let's just never add overlapping prefixes.
38
38
  prefixes: PrefixMatcher[];
39
+ // - Make sure to set this if you just want the prefix values, otherwise you will get all that that prefixes don't match (the prefixes exclude prefix match but where route does not match).
39
40
  excludeDefault?: boolean;
40
41
  };
41
42
 
42
-
43
+ // If you want to match just a simple path, use parsePrefixMatcher. It won't match ===, so get the parent if you want that (and then filter the result).
43
44
  export type PrefixMatcher = {
44
45
  prefix: string;
45
46
  additionalMatches: {
@@ -59,7 +60,6 @@ function matchesPrefix(matcher: PrefixMatcher, path: string): boolean {
59
60
  return false;
60
61
  }
61
62
  }
62
- if (getPathIndex(path, matcher.childKeyIndex) === undefined) return false;
63
63
  return true;
64
64
  }
65
65
  // Checks if they're asking for the exact prefix, which would mean that the children would be the direct children of this prefix matcher.
@@ -451,7 +451,7 @@ export class PathValueArchives {
451
451
 
452
452
  private static valuePathCountCache: { time: number; count: Promise<number> } | undefined;
453
453
  public static async getValuePathCount(): Promise<number> {
454
- const ONE_HOUR = 60 * 60 * 1000;
454
+ const ONE_HOUR = 15 * 60 * 1000;
455
455
  let cache = PathValueArchives.valuePathCountCache;
456
456
  if (cache && Date.now() - cache.time < ONE_HOUR) {
457
457
  return cache.count;
@@ -4,7 +4,7 @@ import { addEpsilons, minusEpsilon } from "../bits";
4
4
  import { logErrors } from "../errors";
5
5
  import { appendToPathStr, getParentPathStr, getPathDepth, getPathIndexAssert, hack_getPackedPathSuffix, hack_setPackedPathSuffix, hack_stripPackedPath } from "../path";
6
6
  import { measureFnc, measureWrap } from "socket-function/src/profiling/measure";
7
- import { IdentityController_getOwnPubKeyShort } from "../-c-identity/IdentityController";
7
+ import { IdentityController_getOwnPubKeyShort, debugNodeId, debugNodeThread } from "../-c-identity/IdentityController";
8
8
  import { pathValueArchives } from "./pathValueArchives";
9
9
  import { blue } from "socket-function/src/formatting/logColors";
10
10
  import { delay, runInfinitePoll } from "socket-function/src/batching";
@@ -694,6 +694,10 @@ class AuthorityPathValueStorage {
694
694
 
695
695
  public addParentSyncs(parentSyncs: { parentPath: string; sourceNodeId: string }[]) {
696
696
  for (let obj of parentSyncs) {
697
+ if (isDebugLogEnabled()) {
698
+ auditLog("RECEIVED PARENT PATH", { parentPath: obj.parentPath, remoteNodeId: debugNodeId(obj.sourceNodeId), remoteNodeThreadId: debugNodeThread(obj.sourceNodeId) });
699
+ }
700
+
697
701
  let decoded = decodeParentFilter(obj.parentPath);
698
702
  let range = decoded ? { start: decoded.start, end: decoded.end } : { start: 0, end: 1 };
699
703
  let parentPath = hack_stripPackedPath(obj.parentPath);
@@ -1492,12 +1492,9 @@ export class PathValueProxyWatcher {
1492
1492
  }
1493
1493
  watcher.countSinceLastFullSync++;
1494
1494
  if (watcher.countSinceLastFullSync > 10) {
1495
- require("debugbreak")(2);
1496
- debugger;
1497
1495
  console.warn(`Watcher ${watcher.debugName} has been unsynced for ${watcher.countSinceLastFullSync} times. This is fine, but maybe optimize it. Why is it cascading?`, { lastUnsyncedAccesses: watcher.lastUnsyncedAccesses, lastUnsyncedParentAccesses: watcher.lastUnsyncedParentAccesses }, watcher.options.watchFunction);
1498
1496
  }
1499
1497
  if (watcher.countSinceLastFullSync > 500) {
1500
- debugger;
1501
1498
  // NOTE: Using forceEqualWrites will also fix this a lot of the time, such as when
1502
1499
  // a write contains random numbers or dates.
1503
1500
  let errorMessage = `Too many attempts (${watcher.countSinceLastFullSync}) to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
@@ -88,5 +88,5 @@ export function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
88
88
  // This really nicely both blocks it from finishing because of the waiting for the promise and also triggers it automatically when the delay finishes. HOWEVER, In practice, the promise should never be required to trigger it. It should trigger when we receive the missing parts of the transaction, which we will be, of course, watching as they're in the path values that we're accessing. The timeout is just in case something goes wrong and we incorrectly think we should receive the values, but we won't. That way, eventually, we do commit the value.
89
89
  // ALSO! Plus the hash I think only stores like 48 bits. So the chance of collision is somewhat high, especially if we're accessing thousands of paths. So sometimes this will trigger even though we're not missing any part just because we had a collision between the path hashes.
90
90
  proxyWatcher.triggerOnPromiseFinish(promise, { waitReason: "Missing transaction part" });
91
- console.error(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
91
+ console.warn(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
92
92
  }
@@ -453,7 +453,7 @@ export class PathFunctionRunner {
453
453
  let fraction = getRoutingOverridePart(callPath.CallId)?.route;
454
454
  if (fraction === undefined) {
455
455
  fraction = PathRouter.getSingleKeyRoute(callPath.CallId);
456
- console.error(`No routing override found for callId, falling back to direct hash`, { callId: callPath.CallId, fraction });
456
+ console.error(`No routing override found for callId, falling back to direct hash`, { callId: callPath.CallId, fraction, domainName: callPath.DomainName, moduleId: callPath.ModuleId, functionId: callPath.FunctionId, callerMachineId: callPath.callerMachineId, callerIP: callPath.callerIP });
457
457
  }
458
458
  // It isn't secondary if it is primary
459
459
  if (shardRange.startFraction <= fraction && fraction < shardRange.endFraction) return 0;
@@ -16,6 +16,61 @@ export async function getGitUncommitted(gitDir = "."): Promise<string[]> {
16
16
  return (await runPromise(`git status --porcelain`, { cwd: gitDir })).split("\n").map(x => x.trim()).filter(x => x);
17
17
  }
18
18
 
19
+ // Cap the diff we hand to the client / AI, so a huge accidental change doesn't
20
+ // blow up the request (or the AI's context window).
21
+ const MAX_DIFF_CHARS = 200_000;
22
+ // Per-file cap for untracked files, so a single large new file doesn't dominate.
23
+ const MAX_UNTRACKED_FILE_CHARS = 20_000;
24
+
25
+ /** Returns a unified-diff-style summary of all uncommitted changes (tracked
26
+ * modifications plus the contents of untracked files), suitable for feeding
27
+ * to an AI for summarization. Output is capped at MAX_DIFF_CHARS. */
28
+ export async function getGitDiff(gitDir = "."): Promise<string> {
29
+ let sections: string[] = [];
30
+
31
+ // Tracked modifications (staged + unstaged) relative to the last commit.
32
+ let trackedDiff = await runPromise(`git diff HEAD`, { cwd: gitDir, quiet: true });
33
+ if (trackedDiff.trim()) {
34
+ sections.push(trackedDiff);
35
+ }
36
+
37
+ // Untracked files don't show up in `git diff HEAD`, so include their
38
+ // contents as all-addition hunks.
39
+ let untrackedFiles = (await runPromise(`git ls-files --others --exclude-standard`, { cwd: gitDir, quiet: true }))
40
+ .split("\n").map(x => x.trim()).filter(x => x);
41
+ for (let file of untrackedFiles) {
42
+ let content: string;
43
+ try {
44
+ content = await fs.promises.readFile(path.join(gitDir, file), "utf8");
45
+ } catch (e: any) {
46
+ sections.push(`--- /dev/null\n+++ b/${file}\n(could not read file: ${e.stack ?? e})`);
47
+ continue;
48
+ }
49
+ let isBinary = false;
50
+ for (let i = 0; i < content.length; i++) {
51
+ if (content.charCodeAt(i) === 0) {
52
+ isBinary = true;
53
+ break;
54
+ }
55
+ }
56
+ if (isBinary) {
57
+ sections.push(`--- /dev/null\n+++ b/${file}\n(binary file, ${content.length} bytes)`);
58
+ continue;
59
+ }
60
+ if (content.length > MAX_UNTRACKED_FILE_CHARS) {
61
+ content = content.slice(0, MAX_UNTRACKED_FILE_CHARS) + `\n(... file truncated)`;
62
+ }
63
+ let addedLines = content.split("\n").map(line => "+" + line).join("\n");
64
+ sections.push(`--- /dev/null\n+++ b/${file} (new file)\n${addedLines}`);
65
+ }
66
+
67
+ let diff = sections.join("\n\n");
68
+ if (diff.length > MAX_DIFF_CHARS) {
69
+ diff = diff.slice(0, MAX_DIFF_CHARS) + `\n\n(... diff truncated, ${diff.length - MAX_DIFF_CHARS} more characters)`;
70
+ }
71
+ return diff;
72
+ }
73
+
19
74
  export async function getLatestRefOnUpstreamBranch(gitDir = ".") {
20
75
  await runPromise(`git fetch`, { cwd: gitDir });
21
76
  return (await runPromise(`git rev-parse @{upstream}`, { cwd: gitDir })).trim();
@@ -29,7 +84,7 @@ export async function commitAndPush(config: {
29
84
  const tempFile = path.join(os.tmpdir(), `commit-message-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`);
30
85
 
31
86
  try {
32
- await fs.promises.writeFile(tempFile, config.message, 'utf8');
87
+ await fs.promises.writeFile(tempFile, config.message, "utf8");
33
88
  await runPromise(`git add --all`, { cwd: config.gitDir });
34
89
  await runPromise(`git commit -F "${tempFile}"`, { cwd: config.gitDir });
35
90
  await runPromise(`git push`, { cwd: config.gitDir });
@@ -237,6 +237,8 @@ export function anyPredictionsPending() {
237
237
  }
238
238
  export const debug_anyPredictionsPending = anyPredictionsPending;
239
239
  export async function waitUntilAllPredictionsFinish() {
240
+ // HACK: Commit an empty function to allow predictions to queue. Now great, and it only works if the predictions can run right away, but... fixes a lot of bugs, most of the time.
241
+ await Querysub.commitAsync(() => { });
240
242
  while (anyPredictionsPending()) {
241
243
  try {
242
244
  await Promise.allSettled(Array.from(pendingPredictedCalls.values()).map(obj => obj.obj.promise));
@@ -131,7 +131,7 @@ function predictCallBase(config: {
131
131
  let result = pathValueSerializer.getPathValue(resultObj) as FunctionResult | undefined;
132
132
  if (!result) return;
133
133
  if (result.lastInternalLoopCount === -1) return;
134
- cleanupPrediction();
134
+ void cleanupPrediction();
135
135
  actualValueFinished.resolve();
136
136
  for (let callback of onPredictionFinishedCallbacks) {
137
137
  callback({ callId: call.CallId, result, functionId: call.FunctionId });
@@ -328,7 +328,7 @@ function predictCallBase(config: {
328
328
  }
329
329
 
330
330
  setTimeout(cleanupPrediction, Querysub.PREDICTION_MAX_LIFESPAN);
331
- function cleanupPrediction() {
331
+ async function cleanupPrediction() {
332
332
  if (didCancel) return;
333
333
 
334
334
  // ALWAYS reject the prediction, in case the function runner server is down.
@@ -338,7 +338,10 @@ function predictCallBase(config: {
338
338
  // which create a memory leak.
339
339
  rejectPrediction();
340
340
 
341
+
341
342
  if (Querysub.AUDIT_PREDICTIONS && predictions) {
343
+ // Wait to receive all parts of the prediction
344
+ await delay(10_000);
342
345
  const afterTime = { time: call.runAtTime.time, version: Number.MAX_SAFE_INTEGER, creatorId: 0 };
343
346
  // Clone predictions, to strip symbols
344
347
  let predictionsCopy = cborEncoder().decode(cborEncoder().encode(predictions)) as typeof predictions;
@@ -0,0 +1,274 @@
1
+ import preact from "preact";
2
+ import { SocketFunction } from "socket-function/SocketFunction";
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { css } from "../../4-dom/css";
5
+ import { MachineServiceController } from "../machineSchema";
6
+ import { Querysub } from "../../4-querysub/QuerysubController";
7
+ import { InputLabel } from "../../library-components/InputLabel";
8
+ import { DropdownSelector } from "../../library-components/DropdownSelector";
9
+ import { closeAllModals } from "../../5-diagnostics/Modal";
10
+ import { callOpenRouterModel, OpenRouterEffort } from "../../diagnostics/logs/errorNotifications2/openRouterHelper";
11
+ import { formatNumber } from "socket-function/src/formatting/format";
12
+ import { t } from "../../2-proxy/schema2";
13
+
14
+ module.hotreload = true;
15
+
16
+ // Models offered for AI commit-message summarization. These are exact OpenRouter
17
+ // model ids and are passed through to the API verbatim. The first is the default.
18
+ export const SUGGEST_MODELS = [
19
+ "anthropic/claude-sonnet-4.6",
20
+ "google/gemini-3.1-flash-lite",
21
+ "qwen/qwen3.5-9b",
22
+ "~google/gemini-flash-latest",
23
+ "x-ai/grok-4.1-fast",
24
+ "nousresearch/hermes-3-llama-3.1-405b",
25
+ "google/gemini-3.1-pro-preview",
26
+ "anthropic/claude-opus-4.7",
27
+ ];
28
+
29
+ const EFFORT_LEVELS: OpenRouterEffort[] = ["none", "low", "medium", "high"];
30
+
31
+ // localStorage keys for persisting the user's OpenRouter preferences.
32
+ const STORAGE_API_KEY = "commitSummary.openRouterApiKey";
33
+ const STORAGE_MODEL = "commitSummary.model";
34
+ const STORAGE_EFFORT = "commitSummary.effort";
35
+
36
+ const DEFAULT_EFFORT: OpenRouterEffort = "low";
37
+
38
+ const COMMIT_PROMPT = `You are an expert software engineer writing a git commit message.
39
+ Summarize the following code changes into a clear, concise commit message.
40
+
41
+ Rules:
42
+ - The first line is a short summary in the imperative mood, max 72 characters.
43
+ - If useful, add bullet points describing notable changes.
44
+ - Output ONLY the raw commit message. No markdown code fences, no preamble, no quotes.
45
+
46
+ Changes (git diff):
47
+ {{DIFF}}`;
48
+
49
+ const DEFAULT_MODEL = SUGGEST_MODELS[0];
50
+
51
+ function loadStored(key: string, fallback: string): string {
52
+ if (typeof localStorage === "undefined") return fallback;
53
+ return localStorage.getItem(key) ?? fallback;
54
+ }
55
+ function saveStored(key: string, value: string) {
56
+ if (typeof localStorage === "undefined") return;
57
+ localStorage.setItem(key, value);
58
+ }
59
+
60
+ /** Strips a surrounding markdown code fence, in case the model ignores the instruction. */
61
+ function cleanCommitMessage(text: string): string {
62
+ text = text.trim();
63
+ let fenceMatch = text.match(/^```[^\n]*\n([\s\S]*?)\n```$/);
64
+ if (fenceMatch) {
65
+ text = fenceMatch[1].trim();
66
+ }
67
+ return text;
68
+ }
69
+
70
+ const rowButtonStyle = css.pad2(12, 8).button.bord2(0, 0, 20).fontWeight("bold");
71
+
72
+ export class CommitModal extends qreact.Component<{
73
+ /** True for the querysub repo, false for the main service repo. */
74
+ isQuerysub?: boolean;
75
+ /** The `git status --porcelain` lines, shown as a file list. */
76
+ changes: string[];
77
+ /** Performs the actual commit + push (and publish, for querysub). */
78
+ onCommit: (message: string) => Promise<void>;
79
+ }> {
80
+ state = t.state({
81
+ message: t.string,
82
+ apiKey: t.string(loadStored(STORAGE_API_KEY, "")),
83
+ model: t.string(loadStored(STORAGE_MODEL, DEFAULT_MODEL)),
84
+ effort: t.atomic(loadStored(STORAGE_EFFORT, DEFAULT_EFFORT) as OpenRouterEffort),
85
+ diff: t.string,
86
+ diffLoading: t.boolean(true),
87
+ summarizing: t.boolean(false),
88
+ committing: t.boolean(false),
89
+ error: t.string,
90
+ lastCost: t.number,
91
+ });
92
+
93
+ componentDidMount() {
94
+ Querysub.onCommitFinished(() => {
95
+ void this.loadDiff();
96
+ });
97
+ }
98
+
99
+ private async loadDiff() {
100
+ try {
101
+ let isQuerysub = Querysub.localRead(() => this.props.isQuerysub);
102
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
103
+ let diff = await controller.getGitDiff.promise({ useQuerysub: isQuerysub });
104
+ Querysub.localCommit(() => {
105
+ this.state.diff = diff;
106
+ this.state.diffLoading = false;
107
+ });
108
+ } catch (e: any) {
109
+ Querysub.localCommit(() => {
110
+ this.state.error = `Failed to load changes: ${e.stack ?? e}`;
111
+ this.state.diffLoading = false;
112
+ });
113
+ }
114
+ }
115
+
116
+ private async doSummarize() {
117
+ let { apiKey, model, effort, diff } = Querysub.localRead(() => ({
118
+ apiKey: this.state.apiKey,
119
+ model: this.state.model,
120
+ effort: this.state.effort,
121
+ diff: this.state.diff,
122
+ }));
123
+ if (!apiKey.trim()) {
124
+ Querysub.localCommit(() => { this.state.error = "Enter an OpenRouter API key first."; });
125
+ return;
126
+ }
127
+ if (!diff) {
128
+ Querysub.localCommit(() => { this.state.error = "No changes available to summarize."; });
129
+ return;
130
+ }
131
+ Querysub.localCommit(() => {
132
+ this.state.summarizing = true;
133
+ this.state.error = "";
134
+ });
135
+ try {
136
+ let result = await callOpenRouterModel({
137
+ apiKey,
138
+ model,
139
+ effort,
140
+ prompt: COMMIT_PROMPT.replace("{{DIFF}}", diff),
141
+ onProgress: partial => {
142
+ Querysub.localCommit(() => { this.state.message = partial; });
143
+ },
144
+ });
145
+ Querysub.localCommit(() => {
146
+ this.state.message = cleanCommitMessage(result.text);
147
+ this.state.lastCost = result.cost ?? 0;
148
+ });
149
+ } catch (e: any) {
150
+ Querysub.localCommit(() => { this.state.error = `Summarize failed: ${e.stack ?? e}`; });
151
+ } finally {
152
+ Querysub.localCommit(() => { this.state.summarizing = false; });
153
+ }
154
+ }
155
+
156
+ private async doCommit() {
157
+ let message = Querysub.localRead(() => this.state.message).trim();
158
+ if (!message) {
159
+ Querysub.localCommit(() => { this.state.error = "Commit message is empty."; });
160
+ return;
161
+ }
162
+ Querysub.localCommit(() => {
163
+ this.state.committing = true;
164
+ this.state.error = "";
165
+ });
166
+ try {
167
+ await this.props.onCommit(message);
168
+ closeAllModals();
169
+ } catch (e: any) {
170
+ Querysub.localCommit(() => {
171
+ this.state.error = `Commit failed: ${e.stack ?? e}`;
172
+ this.state.committing = false;
173
+ });
174
+ }
175
+ }
176
+
177
+ render() {
178
+ let { diff, diffLoading, summarizing, committing } = this.state;
179
+ let diffBytes = diff ? new TextEncoder().encode(diff).length : 0;
180
+
181
+ let summarizeLabel: string;
182
+ if (diffLoading) {
183
+ summarizeLabel = "Summarize (loading changes…)";
184
+ } else if (summarizing) {
185
+ summarizeLabel = "Summarizing…";
186
+ } else {
187
+ summarizeLabel = `Summarize ${diffBytes.toLocaleString()} bytes of changes`;
188
+ }
189
+
190
+ return <div className={css.vbox(12).minWidth(620)}>
191
+ <InputLabel
192
+ label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
193
+ value={this.state.message}
194
+ textarea
195
+ fillWidth
196
+ focusOnMount
197
+ hot
198
+ noEnterKeyCommit
199
+ alwaysUseLatestValueWhenFocused
200
+ style={{ height: 300 }}
201
+ onChangeValue={value => { this.state.message = value; }}
202
+ onKeyDown={e => {
203
+ if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
204
+ e.preventDefault();
205
+ void this.doCommit();
206
+ }
207
+ }}
208
+ />
209
+
210
+ <div className={css.hbox(10).flexWrap("wrap").alignItems("flex-end")}>
211
+ <button
212
+ className={rowButtonStyle.hsl(45, 80, 85)}
213
+ disabled={diffLoading || summarizing}
214
+ onClick={() => void this.doSummarize()}
215
+ >
216
+ 🤖 {summarizeLabel}
217
+ </button>
218
+ <DropdownSelector
219
+ title="Model"
220
+ value={this.state.model}
221
+ onChange={value => {
222
+ this.state.model = value;
223
+ saveStored(STORAGE_MODEL, value);
224
+ }}
225
+ options={SUGGEST_MODELS.map(m => ({ value: m, label: m }))}
226
+ />
227
+ <DropdownSelector
228
+ title="Thinking effort"
229
+ value={this.state.effort}
230
+ onChange={value => {
231
+ this.state.effort = value;
232
+ saveStored(STORAGE_EFFORT, value);
233
+ }}
234
+ options={EFFORT_LEVELS.map(level => ({ value: level, label: level }))}
235
+ />
236
+ {this.state.lastCost && <span className={css.colorhsl(0, 0, 45).fontSize(12)}>
237
+ {formatNumber(1 / this.state.lastCost)} / USD
238
+ </span>}
239
+ </div>
240
+
241
+ <InputLabel
242
+ label="OpenRouter API Key"
243
+ value={this.state.apiKey}
244
+ type="password"
245
+ fillWidth
246
+ hot
247
+ onChangeValue={value => {
248
+ this.state.apiKey = value;
249
+ saveStored(STORAGE_API_KEY, value);
250
+ }}
251
+ />
252
+
253
+ {this.state.error && <div className={
254
+ css.pad2(10).bord2(0, 80, 50).hsl(0, 80, 95).colorhsl(0, 80, 30).whiteSpace("pre-wrap")
255
+ }>
256
+ ⚠️ {this.state.error}
257
+ </div>}
258
+
259
+ <div className={css.hbox(10)}>
260
+ <button
261
+ className={rowButtonStyle.hsl(120, 70, 85)}
262
+ disabled={committing}
263
+ onClick={() => void this.doCommit()}
264
+ >
265
+ {committing ? "Committing…" : "⬆️ Commit & Push"}
266
+ </button>
267
+ </div>
268
+
269
+ <div className={css.vbox(5)}>
270
+ {this.props.changes.map(change => <div>{change}</div>)}
271
+ </div>
272
+ </div>;
273
+ }
274
+ }
@@ -2,13 +2,12 @@ import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
3
  import { MachineServiceController, MachineInfo, ServiceConfig } from "../machineSchema";
4
4
  import { showFullscreenModal } from "../../5-diagnostics/FullscreenModal";
5
- import { InputLabel } from "../../library-components/InputLabel";
6
5
  import { css } from "../../4-dom/css";
7
6
  import { formatTime } from "socket-function/src/formatting/format";
8
7
  import { formatDateJSX } from "../../misc/formatJSX";
9
8
  import { MachineController } from "../machineController";
10
- import { closeAllModals } from "../../5-diagnostics/Modal";
11
9
  import { unique } from "../../misc";
10
+ import { CommitModal } from "./CommitModal";
12
11
 
13
12
  module.hotreload = true;
14
13
 
@@ -56,32 +55,13 @@ export class UpdateButtons extends qreact.Component<{
56
55
  title={gitInfo?.querysubUncommitted.join("\n")}
57
56
  onClick={() => {
58
57
  showFullscreenModal({
59
- content: <div>
60
- <InputLabel
61
- label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
62
- value={""}
63
- textarea
64
- fillWidth
65
- focusOnMount
66
- noEnterKeyCommit
67
- onKeyDown={async e => {
68
- let value = e.currentTarget.value;
69
- if (!value) return;
70
- if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
71
- e.preventDefault();
72
- await controller.commitPushAndPublishQuerysub.promise(value);
73
- closeAllModals();
74
- }
75
- }}
76
- />
77
- <div className={css.vbox(5)}>
78
- {gitInfo?.querysubUncommitted.map(change => {
79
- return <div>
80
- {change}
81
- </div>;
82
- })}
83
- </div>
84
- </div>
58
+ content: <CommitModal
59
+ isQuerysub
60
+ changes={gitInfo?.querysubUncommitted ?? []}
61
+ onCommit={async message => {
62
+ await controller.commitPushAndPublishQuerysub.promise(message);
63
+ }}
64
+ />
85
65
  });
86
66
  }}
87
67
  >
@@ -99,32 +79,12 @@ export class UpdateButtons extends qreact.Component<{
99
79
  title={gitInfo?.uncommitted.join("\n")}
100
80
  onClick={() => {
101
81
  showFullscreenModal({
102
- content: <div>
103
- <InputLabel
104
- label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
105
- value={""}
106
- textarea
107
- fillWidth
108
- focusOnMount
109
- noEnterKeyCommit
110
- onKeyDown={async e => {
111
- let value = e.currentTarget.value;
112
- if (!value) return;
113
- if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
114
- e.preventDefault();
115
- await controller.commitPushService.promise(value);
116
- closeAllModals();
117
- }
118
- }}
119
- />
120
- <div className={css.vbox(5)}>
121
- {gitInfo?.uncommitted.map(change => {
122
- return <div>
123
- {change}
124
- </div>;
125
- })}
126
- </div>
127
- </div>
82
+ content: <CommitModal
83
+ changes={gitInfo?.uncommitted ?? []}
84
+ onCommit={async message => {
85
+ await controller.commitPushService.promise(message);
86
+ }}
87
+ />
128
88
  });
129
89
  }}
130
90
  >