copilot-operator 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/operator.service.js +1 -0
- package/dist/src/orchestrator/taskRunner.js +58 -9
- package/dist/src/transport/copilotTransport.js +289 -199
- package/dist/src/transport/locators.js +2 -0
- package/dist/src/vcs/git.js +22 -1
- package/dist/src/vcs/prepareFromRemote.js +4 -3
- package/dist/web/404.html +2 -2
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +1 -1
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +1 -1
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +1 -1
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +1 -1
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +1 -1
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +1 -1
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +1 -1
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +1 -1
- package/package.json +3 -1
- /package/dist/web/_next/static/{jYvSWuPPywbxckIuz6cNs → 85CblMIRFMFIEI4MTUwrp}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{jYvSWuPPywbxckIuz6cNs → 85CblMIRFMFIEI4MTUwrp}/_ssgManifest.js +0 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 2. Enter does not submit the composer. The Send button has to be clicked.
|
|
11
11
|
* 3. A message cannot consist of an attachment alone; Send stays disabled without text.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { normModel, pageModelFor, sameModel } from './modelMatch.js';
|
|
14
14
|
import { chromium } from 'playwright';
|
|
15
15
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { join } from 'node:path';
|
|
@@ -39,9 +39,28 @@ export function isReplyTimeout(e) {
|
|
|
39
39
|
return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
|
|
40
40
|
}
|
|
41
41
|
const ORIGIN = 'https://m365.cloud.microsoft';
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A group's name for the list. Its row reads the group ("GPT") until a model in it is chosen, and then
|
|
44
|
+
* that model's name; then the name comes from its test id (`gptSubMenuModelTrigger-OpenAI` → "GPT").
|
|
45
|
+
*/
|
|
46
|
+
/** The second line of a group's row: its vendor ("OpenAI", "Anthropic"). */
|
|
47
|
+
function vendorOf(row) {
|
|
48
|
+
return row.raw.split('\n')[1]?.trim() ?? '';
|
|
49
|
+
}
|
|
50
|
+
/** Whether a row's text holds the model's name as whole words, however the page spells it. */
|
|
51
|
+
function containsModel(text, name) {
|
|
52
|
+
const t = ` ${normModel(text)} `;
|
|
53
|
+
const n = normModel(name);
|
|
54
|
+
return n.length > 0 && t.includes(` ${n} `);
|
|
55
|
+
}
|
|
56
|
+
function groupLabel(group, children) {
|
|
57
|
+
if (!children.some((c) => sameModel(c.name, group.name)))
|
|
58
|
+
return group.name;
|
|
59
|
+
const fromId = /^([a-z0-9]+?)SubMenu/i.exec(group.testId)?.[1];
|
|
60
|
+
// "gpt" reads GPT, "claude" reads Claude: short ids are acronyms.
|
|
61
|
+
if (fromId)
|
|
62
|
+
return fromId.length <= 3 ? fromId.toUpperCase() : fromId.charAt(0).toUpperCase() + fromId.slice(1);
|
|
63
|
+
return vendorOf(group) || 'Models';
|
|
45
64
|
}
|
|
46
65
|
/**
|
|
47
66
|
* The chat refused the message: it never became a turn, so no reply is coming.
|
|
@@ -445,15 +464,17 @@ Current URL: ${url}`);
|
|
|
445
464
|
* Renames the current conversation. The sidebar row's overflow menu holds "Rename", the
|
|
446
465
|
* dialog holds an input and a Save button, and Enter does not submit it.
|
|
447
466
|
*/
|
|
448
|
-
async nameChat(chatId, name) {
|
|
467
|
+
async nameChat(chatId, name, opts = {}) {
|
|
449
468
|
const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
|
|
450
|
-
// Waited for, like everything below: a new chat's row appears in the sidebar
|
|
451
|
-
// reply, and `count()` read it as missing and returned without a word (live run 2026-10-03).
|
|
469
|
+
// Waited for, like everything below: a new chat's row appears in the sidebar some seconds after the
|
|
470
|
+
// reply, and `count()` read it as missing and returned without a word (live run 2026-10-03). The run
|
|
471
|
+
// tries again later when it is still not there (see `nameIfStillUnnamed` in taskRunner.ts).
|
|
472
|
+
const waitMs = opts.waitMs ?? 15_000;
|
|
452
473
|
try {
|
|
453
|
-
await row.waitFor({ state: 'attached', timeout:
|
|
474
|
+
await row.waitFor({ state: 'attached', timeout: waitMs });
|
|
454
475
|
}
|
|
455
476
|
catch {
|
|
456
|
-
this.emit('chat-name-failed', { chatId, name, error:
|
|
477
|
+
this.emit('chat-name-failed', { chatId, name, error: `the conversation did not appear in the sidebar within ${Math.round(waitMs / 1000)} s` });
|
|
457
478
|
return false;
|
|
458
479
|
}
|
|
459
480
|
/*
|
|
@@ -467,23 +488,41 @@ Current URL: ${url}`);
|
|
|
467
488
|
*/
|
|
468
489
|
let opened = false;
|
|
469
490
|
try {
|
|
470
|
-
await row.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
491
|
+
await row.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
492
|
+
// Briefly: a hover the sidebar takes waits out the page's whole timeout otherwise.
|
|
493
|
+
await row.hover({ timeout: 2_000 }).catch(() => undefined);
|
|
494
|
+
/*
|
|
495
|
+
* The chat's own overflow button, by the chat id it carries; through the row only in a build without
|
|
496
|
+
* that attribute. A mouse click on it was taken by the sidebar over it — the chat's link, the sticky
|
|
497
|
+
* section header — and Playwright retried for the whole minute (live 2026-10-05), so `press` clicks
|
|
498
|
+
* the element itself when the mouse cannot reach it.
|
|
499
|
+
*/
|
|
500
|
+
const byChat = this.p.locator(Rename.moreButtonForChat(chatId)).first();
|
|
501
|
+
const more = (await byChat.count()) > 0
|
|
502
|
+
? byChat
|
|
503
|
+
: row.locator('xpath=ancestor-or-self::*[self::li or self::div][1]').getByRole('button', { name: Rename.moreButtonLabel, exact: true }).first();
|
|
504
|
+
await more.waitFor({ state: 'attached', timeout: 5_000 });
|
|
505
|
+
await this.press(more);
|
|
477
506
|
opened = true;
|
|
478
507
|
const item = this.p.getByRole('menuitem', { name: Rename.menuItem, exact: true }).first();
|
|
479
508
|
await item.waitFor({ state: 'visible', timeout: 5_000 });
|
|
480
|
-
await
|
|
509
|
+
await this.press(item);
|
|
481
510
|
opened = false;
|
|
482
511
|
const input = this.p.locator(Rename.input);
|
|
483
512
|
await input.waitFor({ state: 'visible', timeout: 10_000 });
|
|
484
|
-
|
|
485
|
-
await
|
|
513
|
+
const wanted = name.slice(0, Rename.maxLength);
|
|
514
|
+
await input.fill(wanted);
|
|
515
|
+
await this.press(this.p.getByRole('button', { name: Rename.saveText, exact: true }).first());
|
|
486
516
|
await input.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => undefined);
|
|
517
|
+
// Named only when the sidebar says so: its row's label is the chat's name.
|
|
518
|
+
const shows = async () => ((await row.getAttribute('aria-label').catch(() => null)) ?? '').trim();
|
|
519
|
+
const deadline = Date.now() + 8_000;
|
|
520
|
+
while ((await shows()) !== wanted && Date.now() < deadline)
|
|
521
|
+
await this.p.waitForTimeout(250);
|
|
522
|
+
if ((await shows()) !== wanted) {
|
|
523
|
+
this.emit('chat-name-failed', { chatId, name, error: `the sidebar still shows "${await shows()}"` });
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
487
526
|
this.emit('chat-named', { chatId, name });
|
|
488
527
|
return true;
|
|
489
528
|
}
|
|
@@ -498,6 +537,19 @@ Current URL: ${url}`);
|
|
|
498
537
|
await this.dismissOpenMenu();
|
|
499
538
|
}
|
|
500
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* Clicks an element: with the mouse when it can reach it, else on the element itself. A short wait
|
|
542
|
+
* for the mouse, not the page's minute: in the sidebar another element lies over the target and
|
|
543
|
+
* takes the click, which no amount of retrying changes.
|
|
544
|
+
*/
|
|
545
|
+
async press(target) {
|
|
546
|
+
try {
|
|
547
|
+
await target.click({ timeout: 3_000 });
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
await target.evaluate((el) => el.click(), undefined, { timeout: 5_000 });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
501
553
|
/**
|
|
502
554
|
* Closes whatever menu or dialog is open, without caring which one it is.
|
|
503
555
|
*
|
|
@@ -567,9 +619,14 @@ Current URL: ${url}`);
|
|
|
567
619
|
* Opens the picker and reads what it offers, then closes it again without choosing.
|
|
568
620
|
*
|
|
569
621
|
* The list is whatever this tenant shows today. Nothing is filtered and nothing is
|
|
570
|
-
* translated: a name shown here is the name the chat uses
|
|
571
|
-
*
|
|
572
|
-
*
|
|
622
|
+
* translated: a name shown here is the name the chat uses. `raw` keeps the option's whole text,
|
|
623
|
+
* which is where Microsoft puts the one-line description and any "limit reached" notice.
|
|
624
|
+
*
|
|
625
|
+
* Each option comes with its `locator`: where it sits in the picker, read here and used to choose
|
|
626
|
+
* it again at the start of a run (see `selectModel`). A group is known by its test id and its place,
|
|
627
|
+
* not by its text: once a model of a group is chosen, Copilot writes that model's name on the
|
|
628
|
+
* group's row ("GPT" reads "GPT-5.6 Sol Think deeper"), and read by text the group lost that very
|
|
629
|
+
* model, so the run could not choose it and went on Auto (2026-10-05).
|
|
573
630
|
*/
|
|
574
631
|
async listModels() {
|
|
575
632
|
const button = await this.resolveModelButton();
|
|
@@ -582,19 +639,23 @@ Current URL: ${url}`);
|
|
|
582
639
|
await this.closeMenu();
|
|
583
640
|
return { options: [], current, note: 'The model picker opened nothing that could be read.' };
|
|
584
641
|
}
|
|
585
|
-
|
|
586
|
-
await this.
|
|
642
|
+
// The top menu only: a group's submenu can be on screen with it (2026-10-05, Claude's above GPT's).
|
|
643
|
+
const shown = await this.readMenuRows();
|
|
644
|
+
const top = this.topRows(shown);
|
|
645
|
+
await this.captureMenu('top', shown);
|
|
587
646
|
await this.closeMenu();
|
|
588
|
-
const
|
|
647
|
+
const strip = (r) => ({ name: r.name, raw: r.raw, selected: r.selected, disabled: r.disabled, role: r.role });
|
|
648
|
+
const options = top.filter((r) => !r.opensSubmenu).map((r) => ({ ...strip(r), locator: { role: r.role, index: r.index } }));
|
|
589
649
|
const groups = top.filter((r) => r.opensSubmenu);
|
|
590
|
-
const topNames = new Set(top.map((r) => r.name.toLowerCase()));
|
|
591
650
|
// Each group is opened in its own pass, from a freshly opened menu. Walking several
|
|
592
651
|
// submenus in one pass works until one of them closes the one above it, and then the
|
|
593
652
|
// reader silently returns half a list; re-opening costs a second and cannot go wrong.
|
|
594
653
|
for (const group of groups) {
|
|
595
|
-
const
|
|
654
|
+
const where = { testId: group.testId || undefined, index: group.index, name: group.name, ...(vendorOf(group) ? { vendor: vendorOf(group) } : {}) };
|
|
655
|
+
const children = await this.readSubmenu(button, where);
|
|
656
|
+
const name = groupLabel(group, children);
|
|
596
657
|
for (const child of children) {
|
|
597
|
-
options.push({ ...child, group:
|
|
658
|
+
options.push({ ...strip(child), group: name, groupRaw: group.raw, locator: { role: child.role, index: child.index, group: { ...where, name } } });
|
|
598
659
|
}
|
|
599
660
|
}
|
|
600
661
|
this.emit('models-read', { count: options.length, groups: groups.length, current });
|
|
@@ -636,36 +697,59 @@ Current URL: ${url}`);
|
|
|
636
697
|
/* a diagnostic, never the reason a reading fails */
|
|
637
698
|
}
|
|
638
699
|
}
|
|
639
|
-
/**
|
|
700
|
+
/**
|
|
701
|
+
* Every visible menu row right now, across the menu and whatever submenu is open, in page order.
|
|
702
|
+
*
|
|
703
|
+
* Each row says which menu it is in (`menuOf`, the menu's `aria-labelledby`: a submenu is labelled by
|
|
704
|
+
* the row that opens it) and its place there (`index`), so the same row can be found again without
|
|
705
|
+
* going by its text. Rows are not merged by name: the group row and the model chosen in it carry the
|
|
706
|
+
* same name once that model is in force.
|
|
707
|
+
*/
|
|
640
708
|
async readMenuRows() {
|
|
641
709
|
return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
|
|
642
|
-
const seen = new Set();
|
|
643
710
|
const out = [];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
|
|
662
|
-
role,
|
|
663
|
-
opensSubmenu: submenuAttrs.some((a) => {
|
|
664
|
-
const v = el.getAttribute(a);
|
|
665
|
-
return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
|
|
666
|
-
}),
|
|
667
|
-
});
|
|
711
|
+
const selector = roles.map((r) => `[role="${r}"]`).join(',');
|
|
712
|
+
const counted = new Map();
|
|
713
|
+
// A menu's key: what labels it (a submenu is labelled by the row that opens it), else a mark put
|
|
714
|
+
// on it here, so a menu with no label is told apart from the others all the same.
|
|
715
|
+
// (Written inline: a named function in here is wrapped by the bundler in a helper the page does not have.)
|
|
716
|
+
const w = window;
|
|
717
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
718
|
+
// A menu that is closed is still in the DOM, so visibility is what separates
|
|
719
|
+
// what is on screen from what was on screen a moment ago.
|
|
720
|
+
if (el.getClientRects().length === 0)
|
|
721
|
+
continue;
|
|
722
|
+
const menu = el.closest('[role="menu"],[role="listbox"]');
|
|
723
|
+
const index = counted.get(menu) ?? 0;
|
|
724
|
+
counted.set(menu, index + 1);
|
|
725
|
+
if (menu && !menu.getAttribute('aria-labelledby') && !menu.getAttribute('data-cop-menu')) {
|
|
726
|
+
w.__copMenus = (w.__copMenus ?? 0) + 1;
|
|
727
|
+
menu.setAttribute('data-cop-menu', `cop-menu-${w.__copMenus}`);
|
|
668
728
|
}
|
|
729
|
+
const menuOf = menu ? (menu.getAttribute('aria-labelledby') || menu.getAttribute('data-cop-menu') || '') : '';
|
|
730
|
+
// Marked, so the row can be pointed at in this page without its text.
|
|
731
|
+
el.setAttribute('data-cop-row', `${menuOf}#${index}`);
|
|
732
|
+
const raw = (el.innerText || '').trim();
|
|
733
|
+
const label = el.getAttribute('aria-label')?.trim() ?? '';
|
|
734
|
+
// The first line is the name; the description sits underneath it.
|
|
735
|
+
const name = (raw.split('\n')[0] || label || '').trim();
|
|
736
|
+
if (!name)
|
|
737
|
+
continue;
|
|
738
|
+
out.push({
|
|
739
|
+
name,
|
|
740
|
+
raw,
|
|
741
|
+
selected: selectedAttrs.some((a) => el.getAttribute(a) === 'true'),
|
|
742
|
+
disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
|
|
743
|
+
role: el.getAttribute('role') ?? '',
|
|
744
|
+
opensSubmenu: submenuAttrs.some((a) => {
|
|
745
|
+
const v = el.getAttribute(a);
|
|
746
|
+
return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
|
|
747
|
+
}),
|
|
748
|
+
index,
|
|
749
|
+
menuOf,
|
|
750
|
+
testId: el.getAttribute('data-test-id') ?? el.getAttribute('data-testid') ?? '',
|
|
751
|
+
elId: el.id ?? '',
|
|
752
|
+
});
|
|
669
753
|
}
|
|
670
754
|
return out;
|
|
671
755
|
}, {
|
|
@@ -674,76 +758,144 @@ Current URL: ${url}`);
|
|
|
674
758
|
submenuAttrs: [...Model.submenuAttributes],
|
|
675
759
|
});
|
|
676
760
|
}
|
|
761
|
+
/** The rows of the top menu, read while only it is open: the menu its first row is in. */
|
|
762
|
+
topRows(rows) {
|
|
763
|
+
const key = rows[0]?.menuOf;
|
|
764
|
+
return key === undefined ? [] : rows.filter((r) => r.menuOf === key);
|
|
765
|
+
}
|
|
677
766
|
/**
|
|
678
|
-
* Opens one group and
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* popup container is which, which is Fluent's business and changes with its internals.
|
|
767
|
+
* Opens one group of the open picker and returns the key of its submenu (see `readMenuRows`), or null.
|
|
768
|
+
* The group's row is found by its test id, else by its place among the top rows, else by its text. Its
|
|
769
|
+
* submenu is the menu labelled by that row, or, in a build that does not label it, the menu that
|
|
770
|
+
* appeared when the row was hovered.
|
|
683
771
|
*/
|
|
684
|
-
async
|
|
772
|
+
async openGroup(group) {
|
|
773
|
+
const shown = await this.readMenuRows();
|
|
774
|
+
const groups = this.topRows(shown).filter((r) => r.opensSubmenu);
|
|
775
|
+
// By its test id; else by the vendor its row names ("OpenAI"), which a choice does not change; else by
|
|
776
|
+
// its text, as it reads or contains; else by its place.
|
|
777
|
+
const row = (group.testId ? groups.find((r) => r.testId === group.testId) : undefined) ??
|
|
778
|
+
(group.vendor ? groups.find((r) => vendorOf(r) && sameModel(vendorOf(r), group.vendor)) : undefined) ??
|
|
779
|
+
groups.find((r) => sameModel(r.name, group.name)) ??
|
|
780
|
+
groups.find((r) => containsModel(r.raw, group.name)) ??
|
|
781
|
+
groups.find((r) => r.index === group.index);
|
|
782
|
+
if (!row)
|
|
783
|
+
return null;
|
|
784
|
+
const trigger = this.p.locator(`[data-cop-row="${row.menuOf}#${row.index}"]`).first();
|
|
785
|
+
// Submenus already on screen are not this group's, whatever opened them.
|
|
786
|
+
const before = new Set(shown.filter((r) => r.menuOf !== row.menuOf).map((r) => r.menuOf));
|
|
787
|
+
const opened = async (ms) => {
|
|
788
|
+
const started = Date.now();
|
|
789
|
+
do {
|
|
790
|
+
const rows = (await this.readMenuRows()).filter((r) => r.menuOf !== row.menuOf);
|
|
791
|
+
// The menu labelled by this group's row is its own; a build that labels none gets the one that
|
|
792
|
+
// appeared, never one that was already there (2026-10-05: Claude's was taken for GPT's).
|
|
793
|
+
if (row.elId && rows.some((r) => r.menuOf === row.elId))
|
|
794
|
+
return row.elId;
|
|
795
|
+
const fresh = rows.find((r) => !before.has(r.menuOf) && r.menuOf !== row.elId);
|
|
796
|
+
if (fresh && (!row.elId || Date.now() - started > 1_500))
|
|
797
|
+
return fresh.menuOf;
|
|
798
|
+
await this.p.waitForTimeout(200);
|
|
799
|
+
} while (Date.now() - started < ms);
|
|
800
|
+
return null;
|
|
801
|
+
};
|
|
802
|
+
// Hover is how these open; a click is the fallback for a build that wants one.
|
|
803
|
+
await trigger.hover({ timeout: 3_000 }).catch(() => undefined);
|
|
804
|
+
const hovered = await opened(4_000);
|
|
805
|
+
if (hovered)
|
|
806
|
+
return hovered;
|
|
807
|
+
await trigger.click({ timeout: 3_000 }).catch(() => undefined);
|
|
808
|
+
return await opened(4_000);
|
|
809
|
+
}
|
|
810
|
+
/** Opens a fresh menu, then the group, and reads what is in it. */
|
|
811
|
+
async readSubmenu(button, group) {
|
|
685
812
|
await button.click();
|
|
686
813
|
if (!(await this.waitForPopup()))
|
|
687
814
|
return [];
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return [];
|
|
692
|
-
}
|
|
693
|
-
// Hover is how these open; a click is the fallback for a build that wants one.
|
|
694
|
-
await trigger.hover().catch(() => undefined);
|
|
695
|
-
let children = await this.waitForNewRows(topNames);
|
|
696
|
-
if (children.length === 0) {
|
|
697
|
-
await trigger.click().catch(() => undefined);
|
|
698
|
-
children = await this.waitForNewRows(topNames);
|
|
699
|
-
}
|
|
700
|
-
await this.captureMenu(`group-${groupName.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
|
|
815
|
+
const key = await this.openGroup(group);
|
|
816
|
+
const children = key ? (await this.readMenuRows()).filter((r) => r.menuOf === key) : [];
|
|
817
|
+
await this.captureMenu(`group-${group.name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
|
|
701
818
|
await this.closeMenu();
|
|
702
819
|
return children;
|
|
703
820
|
}
|
|
704
|
-
/**
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
821
|
+
/**
|
|
822
|
+
* The row a locator points at, in a picker this opens (the group included); the menu is left open.
|
|
823
|
+
* The row must still carry the name: a place that now holds another model is not taken, and a model
|
|
824
|
+
* that moved within its menu is found there by name.
|
|
825
|
+
*/
|
|
826
|
+
async openAtLocator(name, locator) {
|
|
827
|
+
const button = await this.resolveModelButton();
|
|
828
|
+
if (!button)
|
|
829
|
+
return { problem: 'This chat does not show a model picker.' };
|
|
830
|
+
await button.click();
|
|
831
|
+
if (!(await this.waitForPopup()))
|
|
832
|
+
return { problem: 'The model picker did not open.' };
|
|
833
|
+
let rows;
|
|
834
|
+
if (locator.group) {
|
|
835
|
+
const key = await this.openGroup(locator.group);
|
|
836
|
+
if (!key)
|
|
837
|
+
return { problem: `the group "${locator.group.name}" of the picker did not open.` };
|
|
838
|
+
rows = (await this.readMenuRows()).filter((r) => r.menuOf === key);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
rows = this.topRows(await this.readMenuRows());
|
|
842
|
+
}
|
|
843
|
+
const choices = rows.filter((r) => !r.opensSubmenu);
|
|
844
|
+
// At its place when it is still there; else wherever its menu has it, by its name or a row containing it.
|
|
845
|
+
const row = choices.find((r) => r.index === locator.index && sameModel(r.name, name)) ??
|
|
846
|
+
choices.find((r) => sameModel(r.name, name)) ??
|
|
847
|
+
choices.find((r) => containsModel(r.raw, name));
|
|
848
|
+
return row ? { row } : { problem: `"${name}" is not where the list was read${locator.group ? ` (in "${locator.group.name}")` : ''}.` };
|
|
714
849
|
}
|
|
715
|
-
/**
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
850
|
+
/** Clicks one row of the open picker, on the element itself: a mouse moving across the menu re-renders the submenus under it. */
|
|
851
|
+
async clickRow(row) {
|
|
852
|
+
return await this.p
|
|
853
|
+
.evaluate((mark) => {
|
|
854
|
+
const el = document.querySelector(`[data-cop-row="${mark}"]`);
|
|
855
|
+
if (!el || el.getClientRects().length === 0)
|
|
856
|
+
return false;
|
|
857
|
+
el.click();
|
|
858
|
+
return true;
|
|
859
|
+
}, `${row.menuOf}#${row.index}`)
|
|
860
|
+
.catch(() => false);
|
|
719
861
|
}
|
|
720
862
|
/**
|
|
721
|
-
* Chooses
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
* A mouse click first travels to the row, and on the way it crosses the other rows of the menu
|
|
725
|
-
* above; Fluent opens or re-renders their submenus as the pointer passes, the row being aimed at
|
|
726
|
-
* is replaced, and Playwright starts again — "element is not stable", "element was detached from
|
|
727
|
-
* the DOM, retrying" — for as long as the page's default timeout allows. Live, that was 38
|
|
728
|
-
* seconds of a window stuck on the model menu with "GPT 5.6 Think deeper" in plain sight, until
|
|
729
|
-
* the window was closed and the task failed with "Target page, context or browser has been
|
|
730
|
-
* closed". A click dispatched on the element does not move the pointer, so nothing closes under
|
|
731
|
-
* it.
|
|
732
|
-
*
|
|
733
|
-
* Either way the attempt is short: the caller reads the model button back to decide whether the
|
|
734
|
-
* choice took, so a click that did not land costs seconds and says so, rather than a minute.
|
|
863
|
+
* Chooses a model at its locator and reads its row again from a fresh menu: chosen only when the
|
|
864
|
+
* row itself is marked (`aria-checked`). The button's text is no proof — it is shortened, and it is
|
|
865
|
+
* the same for a model and its group.
|
|
735
866
|
*/
|
|
736
|
-
async
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
.catch(() => false);
|
|
743
|
-
if (dispatched)
|
|
744
|
-
return;
|
|
867
|
+
async chooseAt(name, locator) {
|
|
868
|
+
const before = await this.currentModel();
|
|
869
|
+
const found = await this.openAtLocator(name, locator);
|
|
870
|
+
if ('problem' in found) {
|
|
871
|
+
await this.closeMenu();
|
|
872
|
+
return { ok: false, current: before, reason: found.problem };
|
|
745
873
|
}
|
|
746
|
-
|
|
874
|
+
if (found.row.disabled) {
|
|
875
|
+
await this.closeMenu();
|
|
876
|
+
return { ok: false, current: before, reason: `"${found.row.name}" is shown but not available right now.` };
|
|
877
|
+
}
|
|
878
|
+
if (found.row.selected) {
|
|
879
|
+
await this.closeMenu();
|
|
880
|
+
this.emit('model-already-selected', { model: found.row.name, buttonShows: before, by: 'locator' });
|
|
881
|
+
return { ok: true, current: found.row.name, by: 'locator' };
|
|
882
|
+
}
|
|
883
|
+
const clicked = await this.clickRow(found.row);
|
|
884
|
+
await this.p.waitForTimeout(800);
|
|
885
|
+
// Whatever the click did, the menu is not left open over the chat.
|
|
886
|
+
await this.closeMenu();
|
|
887
|
+
const again = clicked ? await this.openAtLocator(found.row.name, locator) : { problem: 'the row could not be clicked.' };
|
|
888
|
+
await this.closeMenu();
|
|
889
|
+
const after = await this.currentModel();
|
|
890
|
+
if ('row' in again && again.row.selected) {
|
|
891
|
+
this.emit('model-selected', { model: again.row.name, buttonShows: after, by: 'locator' });
|
|
892
|
+
return { ok: true, current: again.row.name, by: 'locator' };
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
ok: false,
|
|
896
|
+
current: after,
|
|
897
|
+
reason: 'problem' in again ? `after choosing "${name}", ${again.problem}` : `"${name}" was clicked, but the picker does not mark it as chosen (the button shows "${after ?? 'nothing'}").`,
|
|
898
|
+
};
|
|
747
899
|
}
|
|
748
900
|
/**
|
|
749
901
|
* Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
|
|
@@ -835,107 +987,45 @@ Current URL: ${url}`);
|
|
|
835
987
|
return null;
|
|
836
988
|
}
|
|
837
989
|
/**
|
|
838
|
-
* Sets the chat to a model
|
|
990
|
+
* Sets the chat to a model.
|
|
839
991
|
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
992
|
+
* With a `locator` from the list the operator read (see `listModels`), the row is gone to directly.
|
|
993
|
+
* Without one, or when the page moved it, the line-up is read again with fresh locators and the model
|
|
994
|
+
* is found there by name — or under its new name when the page renamed it (see `modelMatch.ts`). The
|
|
995
|
+
* choice counts only when the picker marks the row as chosen, read again from a fresh menu, because a
|
|
996
|
+
* menu item that is out of quota can look clicked and change nothing; when it did not take, the
|
|
997
|
+
* caller is told what the chat is actually on.
|
|
843
998
|
*/
|
|
844
|
-
async selectModel(name) {
|
|
999
|
+
async selectModel(name, opts = {}) {
|
|
845
1000
|
const button = await this.resolveModelButton();
|
|
846
1001
|
if (!button)
|
|
847
1002
|
return { ok: false, current: null, reason: 'This chat does not show a model picker.' };
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
return { ok: false, current: before, reason: 'The model picker did not open.' };
|
|
854
|
-
// The menu is walked rather than replayed from a saved path. A cached path goes stale the
|
|
855
|
-
// moment Microsoft moves a model between groups, and a name is what the user chose.
|
|
856
|
-
const top = await this.readMenuRows();
|
|
857
|
-
// As the page writes it today: "GPT-5.6" and "GPT 5.6" are one model (see `modelMatch.ts`).
|
|
858
|
-
const wanted = (r) => sameModel(r.name, name);
|
|
859
|
-
let target = top.find((r) => wanted(r) && !r.opensSubmenu);
|
|
860
|
-
const inSubmenu = !target;
|
|
861
|
-
if (!target) {
|
|
862
|
-
const topNames = new Set(top.map((r) => r.name.toLowerCase()));
|
|
863
|
-
for (const group of top.filter((r) => r.opensSubmenu)) {
|
|
864
|
-
const trigger = this.menuRow(group.name);
|
|
865
|
-
await trigger.hover().catch(() => undefined);
|
|
866
|
-
let children = await this.waitForNewRows(topNames);
|
|
867
|
-
if (children.length === 0) {
|
|
868
|
-
await trigger.click().catch(() => undefined);
|
|
869
|
-
children = await this.waitForNewRows(topNames);
|
|
870
|
-
}
|
|
871
|
-
const found = children.find(wanted);
|
|
872
|
-
if (found) {
|
|
873
|
-
target = found;
|
|
874
|
-
break;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
1003
|
+
let first = null;
|
|
1004
|
+
if (opts.locator) {
|
|
1005
|
+
first = await this.chooseAt(name, opts.locator);
|
|
1006
|
+
if (first.ok)
|
|
1007
|
+
return first;
|
|
877
1008
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
* so the saved catalogue follows the page (live run 2026-10-04: "GPT 5.6 Think deeper" had become
|
|
884
|
-
* "GPT 5.6 Sol Think deeper", and every run went on the default).
|
|
885
|
-
*/
|
|
886
|
-
const read = await this.listModels().catch(() => null);
|
|
887
|
-
const options = read?.options ?? [];
|
|
888
|
-
const match = pageModelFor(name, options);
|
|
889
|
-
if (match && !sameModel(match.name, name)) {
|
|
890
|
-
const again = await this.selectModel(match.name);
|
|
891
|
-
return { ...again, matched: match.name, options };
|
|
892
|
-
}
|
|
1009
|
+
// The line-up as the page shows it now, each model with its place in it.
|
|
1010
|
+
const read = await this.listModels().catch(() => null);
|
|
1011
|
+
const options = read?.options ?? [];
|
|
1012
|
+
const match = options.find((o) => sameModel(o.name, name)) ?? pageModelFor(name, options);
|
|
1013
|
+
if (!match?.locator) {
|
|
893
1014
|
const offered = options.filter((o) => !o.disabled).map((o) => o.name);
|
|
894
1015
|
return {
|
|
895
1016
|
ok: false,
|
|
896
|
-
current:
|
|
897
|
-
reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}
|
|
1017
|
+
current: await this.currentModel(),
|
|
1018
|
+
reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.` +
|
|
1019
|
+
(first?.reason ? ` (At the place the list was read: ${first.reason})` : ''),
|
|
898
1020
|
...(options.length > 0 ? { options } : {}),
|
|
899
1021
|
};
|
|
900
1022
|
}
|
|
901
|
-
|
|
902
|
-
* Already the one in force: close the menu and say so, without clicking.
|
|
903
|
-
*
|
|
904
|
-
* The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
|
|
905
|
-
* reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
|
|
906
|
-
* model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
|
|
907
|
-
* click it used to get did nothing in Copilot: the row already in force ignores it, the menu
|
|
908
|
-
* stayed open over the composer, and the run went on with the model list hanging on screen.
|
|
909
|
-
*/
|
|
910
|
-
if (target.selected) {
|
|
911
|
-
await this.closeMenu();
|
|
912
|
-
this.emit('model-already-selected', { model: target.name, buttonShows: before });
|
|
913
|
-
return { ok: true, current: target.name };
|
|
914
|
-
}
|
|
915
|
-
if (target.disabled) {
|
|
916
|
-
await this.closeMenu();
|
|
917
|
-
return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
|
|
918
|
-
}
|
|
919
|
-
await this.clickMenuRow(target.name, inSubmenu);
|
|
920
|
-
await this.p.waitForTimeout(1_000);
|
|
921
|
-
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
922
|
-
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
923
|
-
await this.closeMenu();
|
|
924
|
-
const after = await this.currentModel();
|
|
925
|
-
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
926
|
-
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So the shown value
|
|
927
|
-
// agrees when it is the row's name with words dropped from its end, and the row's full name
|
|
928
|
-
// is what gets reported back, because that is what was chosen and what will be asked for
|
|
929
|
-
// next time. A piece from anywhere used to count: "Think deeper" on the button passed for
|
|
930
|
-
// "GPT-5.6 Sol Think deeper" (2026-10-05), and the run said it was on a model it was not.
|
|
931
|
-
if (buttonShows(after, target.name)) {
|
|
932
|
-
this.emit('model-selected', { model: target.name, buttonShows: after });
|
|
933
|
-
return { ok: true, current: target.name };
|
|
934
|
-
}
|
|
1023
|
+
const chosen = await this.chooseAt(match.name, match.locator);
|
|
935
1024
|
return {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1025
|
+
...chosen,
|
|
1026
|
+
...(chosen.ok && !sameModel(match.name, name) ? { matched: match.name } : {}),
|
|
1027
|
+
...(chosen.ok ? { by: opts.locator ? 'reread' : 'name' } : {}),
|
|
1028
|
+
options,
|
|
939
1029
|
};
|
|
940
1030
|
}
|
|
941
1031
|
/**
|
|
@@ -241,6 +241,8 @@ export const Url = {
|
|
|
241
241
|
export const Rename = {
|
|
242
242
|
/** Per-chat overflow button in the sidebar. There is one per chat, so scope it to the row. */
|
|
243
243
|
moreButtonLabel: 'More',
|
|
244
|
+
/** The same button by the chat it belongs to (seen live 2026-10-05): found without going through the row. */
|
|
245
|
+
moreButtonForChat: (chatId) => `button[data-chat-history-more-button-conversation-id="${chatId}"]`,
|
|
244
246
|
menuItem: 'Rename',
|
|
245
247
|
/** The dialog's text input. */
|
|
246
248
|
input: '#new-session-name',
|