webhelm 1.0.24 → 1.0.26
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/README.md +1 -1
- package/package.json +1 -1
- package/runtime/chatgpt/attachment-upload.mjs +35 -0
- package/runtime/chatgpt/chatgpt-probe.mjs +14 -10
- package/runtime/chatgpt/chatgpt-queue.mjs +28 -24
- package/runtime/chatgpt/submission-script.mjs +37 -0
- package/runtime/cli.mjs +2 -1
- package/runtime/extension/manifest.json +1 -1
- package/runtime/extension/workspace-manager.mjs +9 -5
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ The daemon-managed Chrome Extension is installed from the npm Runtime and does n
|
|
|
39
39
|
|
|
40
40
|
The compact popup shows daemon connectivity and per-workspace capacity and Session counts, with confirmation before stopping a Session or clearing Sessions in one workspace. The popup does not include a global stop-all action. These actions are executed by the same Node daemon as the CLI, over the existing CDP-backed Extension RPC. No shell access or HTTP listener is granted to the Extension. The persistent ChatGPT queue is not deleted, and queued work may create new Sessions afterward.
|
|
41
41
|
|
|
42
|
-
New installations use the default Chrome tab group `WebHelm` and the idle page `helm.html`. Idle URLs use only `workspace` and `role=idle`; Lease URLs use Chrome's actual `tabId` for initial CDP target discovery. There is no separate slot identity. Every newly created WebHelm-managed tab group (default and custom) is placed at the far
|
|
42
|
+
New installations use the default Chrome tab group `WebHelm` and the idle page `helm.html`. Idle URLs use only `workspace` and `role=idle`; Lease URLs use Chrome's actual `tabId` for initial CDP target discovery. There is no separate slot identity. Every newly created WebHelm-managed tab group (default and custom) is placed at the far left of its Chrome window, immediately after pinned tabs. Existing groups retain their user-chosen position during restoration, legacy migration and Session allocation. Chrome-saved tab group shortcuts in the bookmarks bar are separate from active groups and cannot safely be deduplicated through the Extension API. Legacy `Harness` default groups migrate only when their tabs are idle. Custom workspace names are unchanged; the old `workspace.html` path and lease titles remain readable until their Sessions finish. Closing an allocated Session returns its managed tabs to the idle page, retaining physical Chrome targets, while unleased popup children are closed. Do not manually remove active managed tabs.
|
|
43
43
|
|
|
44
44
|
For a real browser integration check on macOS, run `node test/e2e/extension-control.e2e.mjs`; it creates a temporary isolated Chrome profile and daemon and does not touch the current production Sessions.
|
|
45
45
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Browser-side upload source for the existing WebHelm owned Session.
|
|
2
|
+
// Pure string generation; JSON encoding protects filenames and selector quotes.
|
|
3
|
+
const SELECTOR_JS = "(() => ['#upload-files','#upload-media','input[name=\"upload-media\"]','input[type=\"file\"]'].find(s=>document.querySelector(s))||null)()";
|
|
4
|
+
const OPEN_JS = "(() => {const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').trim()==='Add files and more');if(b)b.click();return !!b;})()";
|
|
5
|
+
const STATE_JS = "(() => ({names:[...document.querySelectorAll('button[aria-label^=\"Remove \"]')].map(b=>(b.getAttribute('aria-label')||'').replace(/^Remove file\\s+\\d+:\\s*/,'').replace(/^Remove\\s+/,'')),pending:[...document.querySelectorAll('[role=\"progressbar\"],[aria-busy=\"true\"],[data-state=\"loading\"]')].some(e=>e.getClientRects().length>0)}))()";
|
|
6
|
+
|
|
7
|
+
export function attachmentUploadScript(paths) {
|
|
8
|
+
if (!Array.isArray(paths) || !paths.length) return "({ready:true})";
|
|
9
|
+
return [
|
|
10
|
+
"return await (async()=>{",
|
|
11
|
+
"const files="+JSON.stringify(paths)+";",
|
|
12
|
+
"const locate=()=>js("+JSON.stringify(SELECTOR_JS)+");",
|
|
13
|
+
"let input=await locate();",
|
|
14
|
+
"if(!input){",
|
|
15
|
+
" await js("+JSON.stringify(OPEN_JS)+");",
|
|
16
|
+
" for(let i=0;i<40&&!input;i++){await sleep(150);input=await locate();}",
|
|
17
|
+
"}",
|
|
18
|
+
"if(!input)throw Error('ChatGPT file input unavailable');",
|
|
19
|
+
"const names=[];",
|
|
20
|
+
"for(const file of files){",
|
|
21
|
+
" await upload_file(input,file);",
|
|
22
|
+
" const name=file.split('/').pop();names.push(name);",
|
|
23
|
+
" const expected=names.filter(n=>n===name).length;",
|
|
24
|
+
" let ready=false;",
|
|
25
|
+
" for(let i=0;i<480;i++){",
|
|
26
|
+
" const state=await js("+JSON.stringify(STATE_JS)+");",
|
|
27
|
+
" if(!state.pending&&state.names.filter(n=>n===name).length>=expected){ready=true;break;}",
|
|
28
|
+
" await sleep(250);",
|
|
29
|
+
" }",
|
|
30
|
+
" if(!ready)throw Error('ChatGPT attachment upload not confirmed: '+name);",
|
|
31
|
+
"}",
|
|
32
|
+
"return {ready:true,names};",
|
|
33
|
+
"})()",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
@@ -2,22 +2,26 @@ export function targetUrl(projectId, threadId, message) {
|
|
|
2
2
|
if (!threadId || !message) {
|
|
3
3
|
throw new Error("thread_id and message are required");
|
|
4
4
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// A thread identity is global; ChatGPT may redirect /c/id to a
|
|
6
|
+
// Project-scoped /g/project-id[-slug]/c/id. Use the ordinary URL.
|
|
7
|
+
return "https://chatgpt.com/c/" + encodeURIComponent(threadId) +
|
|
8
|
+
"?" + new URLSearchParams({ prompt: message });
|
|
9
9
|
}
|
|
10
10
|
export const PROBE_JS =
|
|
11
11
|
`(() => { const visible=e=>!!e&&!!e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden'; const body=(document.body?.innerText||'').toLowerCase(); if(body.includes('could not load this chatgpt conversation'))return {state:'BLOCKED',error:'thread_load_failed'}; if(body.includes('log in to chatgpt')||body.includes('sign in to chatgpt'))return {state:'BLOCKED',error:'authentication_required'}; const stop=[...document.querySelectorAll('button')].some(b=>{if(!visible(b))return false;const l=(b.getAttribute('aria-label')||'').trim().toLowerCase(),t=(b.getAttribute('title')||'').trim().toLowerCase(),d=(b.getAttribute('data-testid')||'').trim().toLowerCase();return ['stop generating','stop answering','stop response','stop'].includes(l)||['stop generating','stop answering'].includes(t)||['stop-button','stop-generating-button'].includes(d)}); if(stop)return {state:'RUNNING',stop_visible:true}; const e=document.querySelector('#prompt-textarea,[contenteditable="true"][data-lexical-editor="true"],[role="textbox"][contenteditable="true"],textarea'); const editable=e?.getAttribute?.('contenteditable')==='true'; return {state:visible(e)?'READY':'NOT_READY',stop_visible:false,composer_text:e?(editable?(e.textContent||''):(e.value||e.textContent||e.innerText||'')):''}; })()`;
|
|
12
12
|
export function classifyProbe(observedUrl, expectedUrl, state) {
|
|
13
13
|
const actual = new URL(observedUrl), expected = new URL(expectedUrl);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const thread = url => {
|
|
15
|
+
const match = url.pathname.split('/c/')[1]?.split('/')[0] || null;
|
|
16
|
+
return match ? decodeURIComponent(match) : null;
|
|
17
|
+
};
|
|
18
|
+
if (actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
|
|
19
|
+
!thread(actual) || thread(actual) !== thread(expected)) return "BLOCKED";
|
|
18
20
|
if (["RUNNING", "BLOCKED"].includes(state?.state)) return state.state;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const prompt = expected.searchParams.get("prompt") || "";
|
|
22
|
+
const normalize = text => String(text || "").replace(/\s+/g, " ").trim();
|
|
23
|
+
if (actual.search && actual.searchParams.get("prompt") !== prompt) return "NOT_READY";
|
|
24
|
+
return state?.state === "READY" && normalize(state.composer_text) === normalize(prompt)
|
|
21
25
|
? "READY"
|
|
22
26
|
: "NOT_READY";
|
|
23
27
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { mkdirSync, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
4
|
+
import { attachmentUploadScript } from "./attachment-upload.mjs";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
6
|
import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
|
|
6
|
-
import {
|
|
7
|
+
import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
8
|
+
import { submissionScript } from "./submission-script.mjs";
|
|
7
9
|
import { DurableQueueCore } from "../durable-queue-core.mjs";
|
|
8
10
|
|
|
9
11
|
const transitions = new Set([
|
|
@@ -23,19 +25,26 @@ const chatgptQueuePolicy = {
|
|
|
23
25
|
one(db, sql, ...args) { return db.prepare(sql).get(...args) || null; },
|
|
24
26
|
rows(db) { return db.prepare("SELECT * FROM turns ORDER BY seq").all(); },
|
|
25
27
|
enqueue(db, request = {}, now) {
|
|
26
|
-
const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false} = request;
|
|
28
|
+
const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false, file=[]} = request;
|
|
29
|
+
if (!Array.isArray(file) || file.length > 4 ||
|
|
30
|
+
file.some(p => typeof p !== "string" || !isAbsolute(p) ||
|
|
31
|
+
!existsSync(p) || !statSync(p).isFile())) {
|
|
32
|
+
throw new TypeError("file must contain at most 4 existing absolute file paths");
|
|
33
|
+
}
|
|
34
|
+
if (file.length && mergeable) throw new TypeError("attachment turns cannot merge");
|
|
35
|
+
const filesJson = JSON.stringify(file);
|
|
27
36
|
const key=idempotency_key ?? idempotencyKey;
|
|
28
|
-
if (!thread_id || !message || !key) throw new TypeError("thread_id, message, idempotency_key required");
|
|
37
|
+
if (!thread_id || (!message && !file.length) || !key) throw new TypeError("thread_id, message, idempotency_key required");
|
|
29
38
|
if (typeof mergeable !== "boolean") throw new TypeError("mergeable must be a boolean");
|
|
30
39
|
const merged=this.one(db, "SELECT * FROM merged_requests WHERE idem_key=?", key);
|
|
31
40
|
if (merged) {
|
|
32
|
-
if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
41
|
+
if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id || filesJson!=="[]") throw new Error("idempotency key reused with different payload");
|
|
33
42
|
const row=this.one(db, "SELECT * FROM turns WHERE id=?", merged.queue_id);
|
|
34
43
|
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
35
44
|
}
|
|
36
45
|
const same=this.one(db, "SELECT * FROM turns WHERE idem_key=?", key);
|
|
37
46
|
if (same) {
|
|
38
|
-
if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
47
|
+
if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id || (same.files_json||"[]")!==filesJson) throw new Error("idempotency key reused with different payload");
|
|
39
48
|
return {ok:true,status:"queued",queue_id:same.id,thread_id:same.thread_id,state:same.state};
|
|
40
49
|
}
|
|
41
50
|
const tail=mergeable ? this.one(db, "SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1", thread_id) : null;
|
|
@@ -49,7 +58,7 @@ const chatgptQueuePolicy = {
|
|
|
49
58
|
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
50
59
|
}
|
|
51
60
|
const id=randomUUID();
|
|
52
|
-
db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,?,'QUEUED'
|
|
61
|
+
db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable,files_json) VALUES(?,?,?,?,?,'QUEUED',?,?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0,filesJson);
|
|
53
62
|
return {ok:true,status:"queued",queue_id:id,thread_id,state:"QUEUED"};
|
|
54
63
|
},
|
|
55
64
|
claim(db, owner, now, leaseSeconds) {
|
|
@@ -115,6 +124,9 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
115
124
|
db.exec(
|
|
116
125
|
`CREATE TABLE IF NOT EXISTS turns(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,idem_key TEXT NOT NULL UNIQUE,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,state TEXT NOT NULL,lease_owner TEXT,lease_until REAL,next_check_at REAL NOT NULL,created_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,receipt TEXT,mergeable INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS turns_thread_order ON turns(thread_id,seq); CREATE TABLE IF NOT EXISTS merged_requests(idem_key TEXT PRIMARY KEY,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,queue_id TEXT NOT NULL);`,
|
|
117
126
|
);
|
|
127
|
+
if (!db.prepare("PRAGMA table_info(turns)").all().some(row => row.name === "files_json")) {
|
|
128
|
+
db.exec("ALTER TABLE turns ADD COLUMN files_json TEXT NOT NULL DEFAULT '[]'");
|
|
129
|
+
}
|
|
118
130
|
super(db, chatgptQueuePolicy, now, {
|
|
119
131
|
concurrency: Math.min(4, Math.max(1, Number(concurrency) || 4)), intervalMs,
|
|
120
132
|
});
|
|
@@ -211,7 +223,7 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
211
223
|
let state = "NOT_READY";
|
|
212
224
|
while (Date.now() < deadline) {
|
|
213
225
|
const source =
|
|
214
|
-
`await (async()=>{ const page=await page_info(); const state=await js(${
|
|
226
|
+
`return await (async()=>{ const page=await page_info(); const state=await js(${
|
|
215
227
|
JSON.stringify(PROBE_JS)
|
|
216
228
|
}); if(state?.state==='READY' && !(await js(${
|
|
217
229
|
JSON.stringify(SEND_ENABLED_JS)
|
|
@@ -226,20 +238,12 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
226
238
|
async _send(item) {
|
|
227
239
|
const sid = this.sessions.get(item.id);
|
|
228
240
|
if (!sid) throw new Error("missing prepared browser session");
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
}) && Boolean((gate.composer_text||'').trim()) && gateUrl===${
|
|
236
|
-
JSON.stringify(expected)
|
|
237
|
-
} && await js(${
|
|
238
|
-
JSON.stringify(CLICK_SEND_JS)
|
|
239
|
-
})){ const until=Date.now()+30000; let disabled=false; while(Date.now()<until){ disabled=!(await js(${
|
|
240
|
-
JSON.stringify(SEND_ENABLED_JS)
|
|
241
|
-
})); if(disabled) break; await new Promise(resolve=>setTimeout(resolve,200)); } receipt=disabled?{verified_by:'send-button-disabled'}:{diagnostic:'post_submit_send_state_unverified'}; } return receipt; })()`;
|
|
242
|
-
return this._exec(sid, source);
|
|
241
|
+
const attachments = JSON.parse(item.files_json || "[]");
|
|
242
|
+
if (attachments.length) {
|
|
243
|
+
const upload = await this._exec(sid, attachmentUploadScript(attachments));
|
|
244
|
+
if (!upload?.ready) throw new Error("ChatGPT attachments not ready for Send");
|
|
245
|
+
}
|
|
246
|
+
return this._exec(sid, submissionScript(item));
|
|
243
247
|
}
|
|
244
248
|
async _finish(id) {
|
|
245
249
|
const sid = this.sessions.get(id);
|
|
@@ -293,7 +297,7 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
293
297
|
}
|
|
294
298
|
this.transition(item.id, owner, "CHECKING", "SENDING");
|
|
295
299
|
const receipt = await this._send(item);
|
|
296
|
-
if (
|
|
300
|
+
if (!["send-button-disabled", "user-turn"].includes(receipt?.verified_by)) {
|
|
297
301
|
this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
|
|
298
302
|
receipt: receipt?.diagnostic || "missing_user_turn_receipt",
|
|
299
303
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PROBE_JS } from "./chatgpt-probe.mjs";
|
|
2
|
+
import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
3
|
+
|
|
4
|
+
/** Source executed inside a caller-owned WebHelm Session, never a separate tab. */
|
|
5
|
+
export function submissionScript(item) {
|
|
6
|
+
const prompt = item.message.slice(0, 120);
|
|
7
|
+
const selectorJS = "(() => {for(const s of ['button[aria-label=\"Send\"]','button[data-testid=\"send-button\"]','button[aria-label=\"Send prompt\"]']){const b=document.querySelector(s);if(b&&b.getClientRects().length&&!b.disabled)return s;}return null;})()";
|
|
8
|
+
const userTurnsJS = "(() => [...document.querySelectorAll('h4[data-conversation-role=\"user\"]')].filter(h=>(h.parentElement?.innerText||'').includes(" +
|
|
9
|
+
JSON.stringify(prompt) + ")).length)()";
|
|
10
|
+
return [
|
|
11
|
+
"return await (async()=>{",
|
|
12
|
+
"const gate=await js("+JSON.stringify(PROBE_JS)+");",
|
|
13
|
+
"const url=new URL((await page_info()).url);",
|
|
14
|
+
"const actualId=url.pathname.split('/c/')[1]?.split('/')[0]||'';",
|
|
15
|
+
"if(decodeURIComponent(actualId)!=="+JSON.stringify(item.thread_id)+
|
|
16
|
+
")return {diagnostic:'thread_redirect_mismatch'};",
|
|
17
|
+
"if(gate?.state!=='READY'||!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
|
|
18
|
+
" return {diagnostic:'pre_send_gate_not_ready'};",
|
|
19
|
+
"if((gate.composer_text||'').replace(/\\s+/g,' ').trim()!=="+
|
|
20
|
+
JSON.stringify(item.message.replace(/\s+/g,' ').trim())+")",
|
|
21
|
+
|
|
22
|
+
" return {diagnostic:'pre_send_gate_not_ready'};",
|
|
23
|
+
"const selector=await js("+JSON.stringify(selectorJS)+");",
|
|
24
|
+
"if(!selector)return {diagnostic:'send_control_not_found'};",
|
|
25
|
+
"const before=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
|
|
26
|
+
"await click(selector);",
|
|
27
|
+
"for(let i=0;i<140;i++){",
|
|
28
|
+
" const count=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
|
|
29
|
+
" if(count>before)return {verified_by:'user-turn'};",
|
|
30
|
+
" if(!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
|
|
31
|
+
" return {verified_by:'send-button-disabled'};",
|
|
32
|
+
" await sleep(250);",
|
|
33
|
+
"}",
|
|
34
|
+
"return {diagnostic:'post_submit_user_turn_not_confirmed_no_resend'};",
|
|
35
|
+
"})()",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
package/runtime/cli.mjs
CHANGED
|
@@ -58,7 +58,8 @@ async function runPlatform(args) {
|
|
|
58
58
|
if (sid && auto) throw Error('--session-id and --auto-session are mutually exclusive');
|
|
59
59
|
if (platform === 'chatgpt' && action === 'new-turn' && mode === 'async') {
|
|
60
60
|
if (sid || auto) throw Error('async new-turn cannot use browser session options');
|
|
61
|
-
if (!config.thread_id || !config.message) throw Error('thread_id and message required');
|
|
61
|
+
if (!config.thread_id || (!config.message && !config.file?.length)) throw Error('thread_id and message or file required');
|
|
62
|
+
if (config.file !== undefined && (!Array.isArray(config.file) || config.file.length > 4 || config.file.some(f => typeof f !== 'string' || !path.isAbsolute(f) || !fs.existsSync(f)))) throw Error('file must contain at most 4 existing absolute paths');
|
|
62
63
|
if (typeof config.mergeable !== 'undefined' && typeof config.mergeable !== 'boolean') throw Error('mergeable must be a boolean');
|
|
63
64
|
const receipt = await request({op:'chatgpt-new-turn-enqueue', config: {...config, idempotency_key: config.idempotency_key || randomUUID(), mode}});
|
|
64
65
|
if (!receipt.ok || !receipt.queue_id || receipt.thread_id !== String(config.thread_id)) throw Error('ChatGPT queue returned an invalid acceptance receipt');
|
|
@@ -242,10 +242,14 @@ export class WorkspaceManager {
|
|
|
242
242
|
return await this.chrome.tabs.query({ groupId });
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
// New groups belong
|
|
246
|
-
// insertion policy.
|
|
247
|
-
async
|
|
248
|
-
await this.
|
|
245
|
+
// New groups belong immediately after pinned tabs, regardless of Chrome's
|
|
246
|
+
// insertion policy. Existing groups keep their order on restore and lease.
|
|
247
|
+
async positionNewGroupLeftmost(groupId) {
|
|
248
|
+
const groupTabs = await this.groupTabs(groupId);
|
|
249
|
+
if (!groupTabs.length) return;
|
|
250
|
+
const windowTabs = await this.chrome.tabs.query({ windowId: groupTabs[0].windowId });
|
|
251
|
+
const firstUnpinned = windowTabs.filter(tab => tab.pinned).length;
|
|
252
|
+
await this.chrome.tabGroups.move(groupId, { index: firstUnpinned });
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
tabUrl(tab) {
|
|
@@ -497,7 +501,7 @@ export class WorkspaceManager {
|
|
|
497
501
|
for (const tab of created) this.markOwned(tab.id);
|
|
498
502
|
const groupId = await this.chrome.tabs.group({ tabIds: created.map((tab) => tab.id) });
|
|
499
503
|
await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
|
|
500
|
-
await this.
|
|
504
|
+
await this.positionNewGroupLeftmost(groupId);
|
|
501
505
|
for (const tab of created) await this.waitForIdleTab(tab.id, name);
|
|
502
506
|
return await this.status(name);
|
|
503
507
|
}
|