Skip to content

Commit f1a8a2f

Browse files
ahpxexjackwener
andauthored
fix(chatwise): target main composer in electron UI (#427)
Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 1794933 commit f1a8a2f

4 files changed

Lines changed: 350 additions & 66 deletions

File tree

clis/chatwise/ask.js

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
2-
import { selectorError } from '@jackwener/opencli/errors';
2+
import { selectorError, TimeoutError } from '@jackwener/opencli/errors';
3+
import {
4+
buildChatwiseInjectTextJs,
5+
buildChatwiseMessageCountJs,
6+
buildChatwiseResponseAfterJs,
7+
normalizeTimeout,
8+
} from './utils.js';
39
export const askCommand = cli({
410
site: 'chatwise',
511
name: 'ask',
@@ -15,34 +21,11 @@ export const askCommand = cli({
1521
columns: ['Role', 'Text'],
1622
func: async (page, kwargs) => {
1723
const text = kwargs.text;
18-
const timeout = parseInt(kwargs.timeout, 10) || 30;
24+
const timeout = normalizeTimeout(kwargs.timeout);
1925
// Snapshot content length
20-
const beforeLen = await page.evaluate(`
21-
(function() {
22-
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
23-
return msgs.length;
24-
})()
25-
`);
26+
const beforeLen = await page.evaluate(buildChatwiseMessageCountJs());
2627
// Send message
27-
const injected = await page.evaluate(`
28-
(function(text) {
29-
let composer = document.querySelector('textarea');
30-
if (!composer) {
31-
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
32-
composer = editables.length > 0 ? editables[editables.length - 1] : null;
33-
}
34-
if (!composer) return false;
35-
composer.focus();
36-
if (composer.tagName === 'TEXTAREA') {
37-
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
38-
setter.call(composer, text);
39-
composer.dispatchEvent(new Event('input', { bubbles: true }));
40-
} else {
41-
document.execCommand('insertText', false, text);
42-
}
43-
return true;
44-
})(${JSON.stringify(text)})
45-
`);
28+
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
4629
if (!injected)
4730
throw selectorError('ChatWise input element');
4831
await page.wait(0.5);
@@ -53,25 +36,15 @@ export const askCommand = cli({
5336
let response = '';
5437
for (let i = 0; i < maxPolls; i++) {
5538
await page.wait(pollInterval);
56-
const result = await page.evaluate(`
57-
(function(prevLen) {
58-
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
59-
if (msgs.length <= prevLen) return null;
60-
const last = msgs[msgs.length - 1];
61-
const text = last.innerText || last.textContent;
62-
return text ? text.trim() : null;
63-
})(${beforeLen})
64-
`);
39+
const result = await page.evaluate(buildChatwiseResponseAfterJs(beforeLen, text));
6540
if (result) {
66-
response = result;
67-
break;
41+
const next = String(result).trim();
42+
if (next === response) break;
43+
response = next;
6844
}
6945
}
7046
if (!response) {
71-
return [
72-
{ Role: 'User', Text: text },
73-
{ Role: 'System', Text: `No response within ${timeout}s.` },
74-
];
47+
throw new TimeoutError('ChatWise response', timeout, 'Confirm ChatWise is done generating, then retry with a larger --timeout if needed.');
7548
}
7649
return [
7750
{ Role: 'User', Text: text },

clis/chatwise/composer.test.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { JSDOM } from 'jsdom';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { ArgumentError, TimeoutError } from '@jackwener/opencli/errors';
4+
import {
5+
buildChatwiseInjectTextJs,
6+
buildChatwiseMessageCountJs,
7+
buildChatwiseResponseAfterJs,
8+
normalizeTimeout,
9+
scoreChatwiseComposerCandidate,
10+
selectBestChatwiseComposer,
11+
} from './utils.js';
12+
import { askCommand } from './ask.js';
13+
14+
function candidate(overrides = {}) {
15+
return {
16+
index: 0,
17+
hidden: false,
18+
role: 'textbox',
19+
classes: 'cm-content cm-lineWrapping',
20+
editorClasses: 'cm-editor',
21+
ariaLabel: '',
22+
placeholder: '',
23+
text: '',
24+
rect: { y: 0, h: 30 },
25+
...overrides,
26+
};
27+
}
28+
29+
function runBrowserScript(html, script) {
30+
const dom = new JSDOM(html, { url: 'app://chatwise.local/', runScripts: 'outside-only' });
31+
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
32+
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 32 });
33+
dom.window.HTMLElement.prototype.getClientRects = () => [{ length: 1 }];
34+
dom.window.document.execCommand = () => false;
35+
return { dom, result: dom.window.eval(script) };
36+
}
37+
38+
function makePage(evaluateResults = []) {
39+
const evaluate = vi.fn();
40+
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
41+
evaluate.mockResolvedValue(null);
42+
return {
43+
evaluate,
44+
wait: vi.fn().mockResolvedValue(undefined),
45+
pressKey: vi.fn().mockResolvedValue(undefined),
46+
};
47+
}
48+
49+
describe('chatwise composer selection', () => {
50+
it('prefers the main composer over auxiliary contenteditable editors', () => {
51+
const mainComposer = candidate({
52+
index: 0,
53+
placeholder: 'placeholder Enter a message here, press ⏎ to send',
54+
rect: { y: 860, h: 32 },
55+
});
56+
const optionalDescription = candidate({
57+
index: 1,
58+
placeholder: 'placeholder Optional description',
59+
editorClasses: 'cm-editor simple-editor',
60+
rect: { y: 400, h: 32 },
61+
});
62+
const userContext = candidate({
63+
index: 2,
64+
text: '# User Context Document',
65+
editorClasses: 'cm-editor simple-editor',
66+
rect: { y: 460, h: 1200 },
67+
});
68+
69+
expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
70+
scoreChatwiseComposerCandidate(optionalDescription, 900),
71+
);
72+
expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
73+
scoreChatwiseComposerCandidate(userContext, 900),
74+
);
75+
76+
expect(selectBestChatwiseComposer([
77+
optionalDescription,
78+
userContext,
79+
mainComposer,
80+
], 900)?.index).toBe(0);
81+
});
82+
83+
it('rejects hidden or low-confidence candidates instead of injecting into the wrong editor', () => {
84+
expect(selectBestChatwiseComposer([
85+
candidate({
86+
index: 0,
87+
hidden: true,
88+
placeholder: 'Enter a message here, press ⏎ to send',
89+
rect: { y: 860, h: 32 },
90+
}),
91+
], 900)).toBeNull();
92+
93+
expect(selectBestChatwiseComposer([
94+
candidate({
95+
index: 1,
96+
placeholder: 'Optional description',
97+
editorClasses: 'cm-editor simple-editor',
98+
rect: { y: 860, h: 32 },
99+
}),
100+
candidate({
101+
index: 2,
102+
text: '# User Context Document',
103+
editorClasses: 'cm-editor simple-editor',
104+
rect: { y: 870, h: 32 },
105+
}),
106+
], 900)).toBeNull();
107+
});
108+
109+
it('injects text into the scored main composer instead of the last contenteditable', () => {
110+
const html = `
111+
<div class="cm-editor simple-editor">
112+
<div class="cm-placeholder">Optional description</div>
113+
<div id="optional" contenteditable="true" role="textbox"></div>
114+
</div>
115+
<div class="cm-editor">
116+
<div class="cm-placeholder">Enter a message here, press ⏎ to send</div>
117+
<div id="main" class="cm-content" contenteditable="true" role="textbox"></div>
118+
</div>
119+
<div class="cm-editor simple-editor">
120+
<div id="context" contenteditable="true" role="textbox"># User Context Document</div>
121+
</div>
122+
`;
123+
124+
const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));
125+
126+
expect(result).toBe(true);
127+
expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello');
128+
expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
129+
expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
130+
});
131+
132+
it('fails injection when only auxiliary editors are present', () => {
133+
const html = `
134+
<div class="cm-editor simple-editor">
135+
<div class="cm-placeholder">Optional description</div>
136+
<div id="optional" contenteditable="true" role="textbox"></div>
137+
</div>
138+
<div class="cm-editor simple-editor">
139+
<div id="context" contenteditable="true" role="textbox"># User Context Document</div>
140+
</div>
141+
`;
142+
143+
const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));
144+
145+
expect(result).toBe(false);
146+
expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
147+
expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
148+
});
149+
150+
it('reads only real message wrapper content after the previous count', () => {
151+
const html = `
152+
<div class="group/message">old message</div>
153+
<div class="timestamp">12:00</div>
154+
<div class="group/message">new assistant answer</div>
155+
`;
156+
157+
expect(runBrowserScript(html, buildChatwiseMessageCountJs()).result).toBe(2);
158+
expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBe('new assistant answer');
159+
});
160+
161+
it('does not treat the user prompt wrapper as an assistant response', () => {
162+
const html = `
163+
<div class="group/message">old message</div>
164+
<div class="group/message">user prompt</div>
165+
`;
166+
167+
expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBeNull();
168+
});
169+
170+
it('validates timeout explicitly', () => {
171+
expect(normalizeTimeout(undefined)).toBe(30);
172+
expect(() => normalizeTimeout('0')).toThrow(ArgumentError);
173+
expect(() => normalizeTimeout('301')).toThrow(ArgumentError);
174+
});
175+
176+
it('fails fast when ask times out instead of returning a System success row', async () => {
177+
const page = makePage([
178+
1,
179+
true,
180+
null,
181+
]);
182+
183+
await expect(askCommand.func(page, { text: 'hello', timeout: 1 }))
184+
.rejects.toBeInstanceOf(TimeoutError);
185+
});
186+
});

clis/chatwise/send.js

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
22
import { selectorError } from '@jackwener/opencli/errors';
3+
import { buildChatwiseInjectTextJs } from './utils.js';
34
export const sendCommand = cli({
45
site: 'chatwise',
56
name: 'send',
@@ -12,30 +13,7 @@ export const sendCommand = cli({
1213
columns: ['Status', 'InjectedText'],
1314
func: async (page, kwargs) => {
1415
const text = kwargs.text;
15-
const injected = await page.evaluate(`
16-
(function(text) {
17-
// ChatWise input can be textarea or contenteditable
18-
let composer = document.querySelector('textarea');
19-
if (!composer) {
20-
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
21-
composer = editables.length > 0 ? editables[editables.length - 1] : null;
22-
}
23-
24-
if (!composer) return false;
25-
26-
composer.focus();
27-
28-
if (composer.tagName === 'TEXTAREA') {
29-
// For textarea, set value and dispatch input event
30-
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
31-
nativeInputValueSetter.call(composer, text);
32-
composer.dispatchEvent(new Event('input', { bubbles: true }));
33-
} else {
34-
document.execCommand('insertText', false, text);
35-
}
36-
return true;
37-
})(${JSON.stringify(text)})
38-
`);
16+
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
3917
if (!injected)
4018
throw selectorError('ChatWise input element');
4119
await page.wait(0.5);

0 commit comments

Comments
 (0)