r.bottom)dialog.close()}});
- const requestId=crypto.randomUUID();form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;err.textContent='';try{if(create){const data=await api('/api/task-groups',{method:'POST',body:JSON.stringify({workflowId:select.value,projectId:project(),input:prompt.value})});dialog.close();await selectRun(data.id)}else{await api('/api/runs/'+encodeURIComponent(currentRun)+'/group/tasks',{method:'POST',body:JSON.stringify({agentId:select.value,prompt:prompt.value,requestId,files:files.value.split(',').map(f=>f.trim()).filter(Boolean)})});dialog.close();await returnToPM();await refresh()}}catch(error){err.textContent=error.message}finally{submit.disabled=false}};dialog.showModal();prompt.focus();
+ const requestId=crypto.randomUUID();form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;err.textContent='';try{if(scope!==project())throw new Error('当前项目已切换,请重新打开任务窗口');if(create){if(!select.value&&!sandbox.value)throw new Error('请先配置可用的运行环境或选择模板');const data=await api('/api/task-groups',{method:'POST',body:JSON.stringify({workflowId:select.value,projectId:scope,input:prompt.value,repositoryIds:repositoryChecks.filter(c=>c.checked).map(c=>c.value),...(!select.value?{sandboxProfileId:sandbox.value}:{})})});dialog.close();await selectRun(data.id)}else{await api('/api/runs/'+encodeURIComponent(currentRun)+'/group/tasks',{method:'POST',body:JSON.stringify({agentId:select.value,prompt:prompt.value,requestId,files:files.value.split(',').map(f=>f.trim()).filter(Boolean)})});dialog.close();await returnToPM();await refresh()}}catch(error){err.textContent=error.message}finally{submit.disabled=false}};dialog.showModal();prompt.focus();
}
async function selectRun(id){
if(busy())return toast('请先停止当前回复或等待附件上传完成');
diff --git a/web/collaboration_creation_test.go b/web/collaboration_creation_test.go
new file mode 100644
index 0000000..d97c113
--- /dev/null
+++ b/web/collaboration_creation_test.go
@@ -0,0 +1,16 @@
+package web
+
+import (
+ "os/exec"
+ "testing"
+)
+
+func TestCollaborationResponsibleFilterRequestsAndRaces(t *testing.T) {
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node not installed")
+ }
+ if out, err := exec.Command(node, "collaboration_creation_test.js").CombinedOutput(); err != nil {
+ t.Fatalf("collaboration filtering: %v\n%s", err, out)
+ }
+}
diff --git a/web/collaboration_creation_test.js b/web/collaboration_creation_test.js
new file mode 100644
index 0000000..d4de368
--- /dev/null
+++ b/web/collaboration_creation_test.js
@@ -0,0 +1,48 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync('collaboration.js', 'utf8');
+const paints = [], requests = [], filters = {
+ collabTaskResponsible: {value: 'all'}, collabTaskSearch: {value: 'query'},
+ collabTaskFilter: {value: 'waiting'}, collabTaskFilters: {hidden: false},
+ collabSearchToggle: {setAttribute() {}},
+};
+const context = {
+ URLSearchParams, taskRefreshGeneration: 0, taskRows: [], taskLimit: 60,
+ sessions: [], currentRun: '', active: true, scope: 'p',
+ project: () => context.scope, $id: id => filters[id],
+ api: path => path.startsWith('/api/runs?') ? new Promise(resolve => requests.push({path, resolve})) : Promise.resolve([]),
+ assistant: {refreshShared: async () => {}}, paintSessions() {}, refreshSOPHandoffs: async () => {},
+ paintTasks: () => paints.push(context.taskRows.map(r => r.id)),
+ error: err => {throw err},
+};
+vm.createContext(context);
+vm.runInContext(source.slice(source.indexOf(' async function refresh(){'), source.indexOf(' function label(')), context);
+vm.runInContext(source.slice(source.indexOf(' function toggleTaskSearch('), source.indexOf(" $id('collabSearchToggle').onclick=")), context);
+vm.runInContext(source.split('\n').find(line => line.includes("$id('collabTaskResponsible').onchange=")), context);
+async function flush() {for (let i = 0; i < 20; i++) await Promise.resolve()}
+async function main() {
+ const old = context.refresh();
+ assert.equal(new URLSearchParams(requests[0].path.split('?')[1]).has('responsible'), false);
+ filters.collabTaskResponsible.value = 'me';
+ filters.collabTaskResponsible.onchange();
+ assert.equal(context.taskLimit, 30);
+ assert.equal(new URLSearchParams(requests[1].path.split('?')[1]).get('responsible'), 'me');
+ requests[1].resolve([{id: 'mine', executionContext: {projectId: 'p'}}]); await flush();
+ requests[0].resolve([{id: 'other', executionContext: {projectId: 'p'}}]); await old;
+ assert.equal(context.taskRows.length, 1); assert.equal(context.taskRows[0].id, 'mine');
+ assert(!paints.flat().includes('other'), 'late unfiltered response must never overwrite my tasks');
+ assert.equal(filters.collabTaskSearch.value, 'query', 'owner filter keeps keyword');
+ assert.equal(filters.collabTaskFilter.value, 'waiting', 'owner filter keeps status');
+ context.toggleTaskSearch(false); await flush();
+ assert.equal(filters.collabTaskResponsible.value, 'all');
+ assert.equal(filters.collabTaskSearch.value, '');
+ assert.equal(filters.collabTaskFilter.value, 'all');
+ assert.equal(new URLSearchParams(requests[2].path.split('?')[1]).has('responsible'), false);
+ requests[2].resolve([]); await flush();
+ const previousProject = context.refresh();
+ context.scope = 'other';
+ requests[3].resolve([{id: 'stale-project', executionContext: {projectId: 'p'}}]); await previousProject;
+ assert.equal(context.taskRows.length, 0, 'project change invalidates old response');
+}
+main().catch(err => {console.error(err); process.exit(1)});
diff --git a/web/index.html b/web/index.html
index eb04852..d44a80f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -9,7 +9,7 @@
-
+
@@ -24,7 +24,7 @@
-
+
@@ -53,7 +53,7 @@
@@ -1047,6 +1047,6 @@
确认操作
-
+