feat(projects): add per-project new conversation shortcuts (#4676)
This commit is contained in:
1 parent
1ba91d9e6b
commit
11882aab2a
3 files changed
+381
-1
No files matched your search
+28
-1
@@ -934,7 +934,11 @@ async function newSession(flash, options={}){
|
||||
};
|
||||
if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id;
|
||||
if(options&&options.worktree) reqBody.worktree=true;
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
if(Object.prototype.hasOwnProperty.call(options,'project_id')){
|
||||
reqBody.project_id=options.project_id;
|
||||
} else if(_activeProject&&_activeProject!==NO_PROJECT_FILTER){
|
||||
reqBody.project_id=_activeProject;
|
||||
}
|
||||
// Forward a pre-session toolset override only from the empty composer (#4490).
|
||||
if(!S.session && Array.isArray(S._pendingSessionToolsets)) reqBody.enabled_toolsets=S._pendingSessionToolsets;
|
||||
const modelSelForNew=$('modelSelect');
|
||||
@@ -6144,6 +6148,28 @@ function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){
|
||||
return _attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows);
|
||||
}
|
||||
|
||||
function _attachProjectQuickCreateButton(chip, project){
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.className='project-chip-quick-create';
|
||||
btn.textContent='+';
|
||||
btn.title='New conversation in this project';
|
||||
const stop=function(e){
|
||||
if(!e) return;
|
||||
if(typeof e.preventDefault==='function') e.preventDefault();
|
||||
if(typeof e.stopPropagation==='function') e.stopPropagation();
|
||||
if(typeof e.stopImmediatePropagation==='function') e.stopImmediatePropagation();
|
||||
};
|
||||
btn.onclick=(e)=>{
|
||||
stop(e);
|
||||
_setActiveProjectFilter(project.project_id);
|
||||
void newSession(false,{project_id:project.project_id});
|
||||
};
|
||||
btn.ondblclick=(e)=>{stop(e);};
|
||||
btn.oncontextmenu=(e)=>{stop(e);};
|
||||
chip.appendChild(btn);
|
||||
}
|
||||
|
||||
|
||||
function renderSessionListFromCache(){
|
||||
// #4671: while a profile-switch skeleton is up, bail — _allSessions still holds the
|
||||
@@ -6358,6 +6384,7 @@ function renderSessionListFromCache(){
|
||||
clearTimeout(_lpTimer);_lpTimer=null;_lpHandled=false;
|
||||
chip.classList.remove('long-pressing');
|
||||
},{passive:true});
|
||||
_attachProjectQuickCreateButton(chip,p);
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
// Create button
|
||||
|
||||
@@ -5358,6 +5358,47 @@ main.main > #mainPlugin{display:none;}
|
||||
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);}
|
||||
.project-chip.long-pressing{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);transform:scale(1.05);}
|
||||
.project-chip-quick-create{
|
||||
border:1px solid transparent;
|
||||
border-radius:999px;
|
||||
min-width:16px;
|
||||
min-height:16px;
|
||||
padding:0 4px;
|
||||
font-size:11px;
|
||||
line-height:1;
|
||||
font-weight:700;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:var(--muted);
|
||||
background:transparent;
|
||||
opacity:0;
|
||||
transition:opacity .15s,color .15s,background .15s,border-color .15s,transform .12s;
|
||||
}
|
||||
.project-chip:hover .project-chip-quick-create,
|
||||
.project-chip:focus-within .project-chip-quick-create{
|
||||
opacity:1;
|
||||
color:var(--text);
|
||||
border-color:var(--border2);
|
||||
}
|
||||
.project-chip-quick-create:hover{
|
||||
opacity:1;
|
||||
color:var(--blue);
|
||||
border-color:var(--blue);
|
||||
background:rgba(255,255,255,.08);
|
||||
}
|
||||
@media (hover:none) and (pointer:coarse){
|
||||
.project-chip-quick-create{
|
||||
opacity:.72;
|
||||
min-width:18px;
|
||||
min-height:18px;
|
||||
padding:0 6px;
|
||||
}
|
||||
.project-chip:hover .project-chip-quick-create,
|
||||
.project-chip:focus-within .project-chip-quick-create{
|
||||
opacity:1;
|
||||
}
|
||||
}
|
||||
/* "Unassigned" filter chip — dashed border distinguishes it from real
|
||||
project chips so it reads as a meta-filter ("things without a project")
|
||||
rather than another project. Keeps full color treatment in the active
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Regression coverage for #4676: project-scope quick conversation creation."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
||||
STYLE_CSS = ROOT / "static" / "style.css"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(source: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = source.find(marker)
|
||||
assert start >= 0, f"{name} function not found in static/sessions.js"
|
||||
brace = source.find("{", start)
|
||||
assert brace >= 0, f"{name} declaration has no opening brace"
|
||||
depth = 0
|
||||
for idx in range(brace, len(source)):
|
||||
ch = source[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : idx + 1]
|
||||
raise AssertionError(f"{name} function body not closed")
|
||||
|
||||
|
||||
def test_new_session_uses_explicit_project_override_before_active_filter():
|
||||
src = _read(SESSIONS_JS)
|
||||
assert "Object.prototype.hasOwnProperty.call(options,'project_id')" in src
|
||||
assert "reqBody.project_id=options.project_id" in src
|
||||
|
||||
|
||||
def test_quick_create_button_attaches_filter_align_and_request_path():
|
||||
src = _read(SESSIONS_JS)
|
||||
helper = _extract_function(src, "_attachProjectQuickCreateButton")
|
||||
assert "project-chip-quick-create" in helper
|
||||
assert "_setActiveProjectFilter(project.project_id)" in helper
|
||||
assert "newSession(false,{project_id:project.project_id})" in helper
|
||||
assert "btn.ondblclick" in helper
|
||||
assert "btn.oncontextmenu" in helper
|
||||
|
||||
|
||||
def test_project_quick_create_styles_exist_and_are_discrete_to_pointer_layouts():
|
||||
css = _read(STYLE_CSS)
|
||||
assert ".project-chip-quick-create" in css
|
||||
assert ".project-chip:hover .project-chip-quick-create" in css
|
||||
assert ".project-chip:focus-within .project-chip-quick-create" in css
|
||||
assert ".project-chip-quick-create:hover" in css
|
||||
assert "@media (hover:none) and (pointer:coarse)" in css
|
||||
|
||||
|
||||
def _run_new_session_case(options, active_project=None):
|
||||
src = _read(SESSIONS_JS)
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const path = process.argv[1];
|
||||
const args = JSON.parse(process.argv[2]);
|
||||
const src = fs.readFileSync(path, 'utf8');
|
||||
|
||||
function extractAsyncFunction(source, name) {
|
||||
const marker = `async function ${name}(`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
const brace = source.indexOf('{', source.indexOf(')', start));
|
||||
let depth = 0;
|
||||
for (let i = brace; i < source.length; i++) {
|
||||
if (source[i] === '{') depth += 1;
|
||||
else if (source[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error('function body not closed for ' + name);
|
||||
}
|
||||
|
||||
const newSessionSrc = extractAsyncFunction(src, 'newSession');
|
||||
|
||||
globalThis.window = globalThis;
|
||||
globalThis.document = {
|
||||
baseURI: 'http://example.test/',
|
||||
createElement(tag) {
|
||||
const node = {
|
||||
tagName: String(tag || '').toUpperCase(),
|
||||
children: [],
|
||||
appendChild(child) { this.children.push(child); },
|
||||
textContent: '',
|
||||
value: '',
|
||||
selectedOptions: [{ dataset: { provider: '' } }],
|
||||
dataset: {},
|
||||
};
|
||||
return node;
|
||||
},
|
||||
};
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
globalThis.history = { replaceState: () => {} };
|
||||
globalThis.NO_PROJECT_FILTER = '__none__';
|
||||
globalThis._activeProject = args.activeProject;
|
||||
globalThis._sessionSourceFilter = 'webui';
|
||||
globalThis._newSessionInFlight = null;
|
||||
globalThis._messagesTruncated = false;
|
||||
globalThis._oldestIdx = 0;
|
||||
globalThis.INFLIGHT = {};
|
||||
globalThis.S = {
|
||||
session: args.session || null,
|
||||
toolCalls: [],
|
||||
messages: [],
|
||||
activeProfile: 'default',
|
||||
_pendingSessionToolsets: null,
|
||||
_profileSwitchWorkspace: null,
|
||||
_profileDefaultWorkspace: null,
|
||||
};
|
||||
globalThis._defaultModel = null;
|
||||
globalThis._activeProvider = 'openai';
|
||||
globalThis._emptyComposerModelOverride = null;
|
||||
globalThis._readPersistedModelState = () => null;
|
||||
globalThis._readEmptyComposerModelOverride = () => null;
|
||||
globalThis._clearEmptyComposerModelOverride = () => {};
|
||||
globalThis.$ = (id) => (id === 'modelSelect' ? { value: 'gpt-4', selectedOptions: [{ dataset: { provider: 'openai' } }] } : null);
|
||||
for (const name of [
|
||||
'_setNewSessionPending', 'updateQueueBadge', '_clearPendingSelections',
|
||||
'clearLiveToolCards', 'setComposerStatus', 'setStatus', 'updateSendBtn',
|
||||
'syncTopbar', 'renderMessages', 'startSessionStream', '_setSessionViewedCount',
|
||||
'_setActiveSessionUrl', '_rememberNewChatDraftSession', '_hydrateTodosFromSession',
|
||||
'_setLiveAssistantTps', '_syncCtxIndicator', 'showToast'
|
||||
]) {
|
||||
globalThis[name] = () => {};
|
||||
}
|
||||
globalThis.loadDir = async () => null;
|
||||
globalThis._applyModelToDropdown = () => true;
|
||||
globalThis._modelStateForSelect = () => ({ model: 'gpt-4', model_provider: 'openai' });
|
||||
globalThis._readPersistedModelState = () => null;
|
||||
globalThis.getModelLabel = (v) => v || '';
|
||||
globalThis._defaultModel = null;
|
||||
|
||||
const calls = [];
|
||||
globalThis.api = async (_url, opts) => {
|
||||
calls.push(JSON.parse(opts.body));
|
||||
return { session: { session_id: 's-1', messages: [], model: 'gpt-4', model_provider: 'openai', workspace: null, message_count: 0, last_usage: {} } };
|
||||
};
|
||||
|
||||
eval(newSessionSrc);
|
||||
|
||||
(async () => {
|
||||
await newSession(false, args.options);
|
||||
console.log(JSON.stringify({ body: calls[0] || {} }));
|
||||
})().catch(err => {
|
||||
console.error(String(err && err.stack ? err.stack : err));
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"activeProject": active_project,
|
||||
"options": options,
|
||||
"session": {"session_id": "session-1"},
|
||||
}
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", _DRIVER, str(SESSIONS_JS), json.dumps(payload)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}"
|
||||
)
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])["body"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_new_session_aligns_project_id_override_when_explicitly_set():
|
||||
body = _run_new_session_case({"project_id": "explicit-project"}, active_project="active-project")
|
||||
assert body["project_id"] == "explicit-project"
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_new_session_respects_explicit_project_id_none():
|
||||
body = _run_new_session_case({"project_id": None}, active_project="active-project")
|
||||
assert "project_id" in body
|
||||
assert body["project_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_new_session_falls_back_to_active_project_when_override_missing():
|
||||
body = _run_new_session_case({}, active_project="active-project")
|
||||
assert body["project_id"] == "active-project"
|
||||
|
||||
|
||||
_HELPER = r"""
|
||||
const fs = require('fs');
|
||||
const sessionsSrc = fs.readFileSync(process.argv[1], 'utf8');
|
||||
const params = JSON.parse(process.argv[2]);
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const marker = `function ${name}(`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
const brace = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = brace; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error('function body not closed for ' + name);
|
||||
}
|
||||
|
||||
globalThis.window = globalThis;
|
||||
globalThis.document = {
|
||||
createElement(tag) {
|
||||
return {
|
||||
tagName: String(tag || '').toUpperCase(),
|
||||
className: '',
|
||||
textContent: '',
|
||||
children: [],
|
||||
appendChild(child) { this.children.push(child); },
|
||||
appendChildCallCount: 0,
|
||||
dataset: {},
|
||||
type: '',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis._setActiveProjectFilter = (projectId) => {
|
||||
params.filterProjectId = projectId;
|
||||
params.calls.push({type: 'set-filter', projectId});
|
||||
};
|
||||
globalThis.newSession = async (flash, options) => {
|
||||
params.newSession = {flash, options};
|
||||
params.calls.push({type: 'new-session', flash, options});
|
||||
};
|
||||
|
||||
eval(extractFunction(sessionsSrc, '_attachProjectQuickCreateButton'));
|
||||
|
||||
const chip = {
|
||||
appended: [],
|
||||
appendChild(child) { this.appended.push(child); },
|
||||
};
|
||||
_attachProjectQuickCreateButton(chip, { project_id: params.projectId });
|
||||
const btn = chip.appended[0];
|
||||
const ev = {
|
||||
stopPropagation() { params.stopCount++; },
|
||||
preventDefault() { params.preventCount++; },
|
||||
stopImmediatePropagation() { params.stopImmediateCount++; },
|
||||
};
|
||||
btn.onclick(ev);
|
||||
btn.ondblclick(ev);
|
||||
btn.oncontextmenu(ev);
|
||||
console.log(JSON.stringify({
|
||||
buttonClass: btn.className,
|
||||
buttonTag: btn.tagName,
|
||||
buttonText: btn.textContent,
|
||||
newSession: params.newSession,
|
||||
filterProjectId: params.filterProjectId,
|
||||
stopCount: params.stopCount,
|
||||
preventCount: params.preventCount,
|
||||
stopImmediateCount: params.stopImmediateCount,
|
||||
calls: params.calls,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run_quick_create_case(project_id="example-project"):
|
||||
payload = {
|
||||
"projectId": project_id,
|
||||
"calls": [],
|
||||
"stopCount": 0,
|
||||
"preventCount": 0,
|
||||
"stopImmediateCount": 0,
|
||||
}
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", _HELPER, str(SESSIONS_JS), json.dumps(payload)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"node helper failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}"
|
||||
)
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_project_chip_quick_create_keeps_active_filter_and_uses_project_override():
|
||||
out = _run_quick_create_case("project-123")
|
||||
assert out["buttonClass"] == "project-chip-quick-create"
|
||||
assert out["buttonTag"] == "BUTTON"
|
||||
assert out["buttonText"] == "+"
|
||||
assert out["filterProjectId"] == "project-123"
|
||||
assert out["newSession"] == {"flash": False, "options": {"project_id": "project-123"}}
|
||||
assert {"type": "set-filter", "projectId": "project-123"} in out["calls"]
|
||||
assert {"type": "new-session", "flash": False, "options": {"project_id": "project-123"}} in out["calls"]
|
||||
assert out["stopCount"] >= 3
|
||||
assert out["preventCount"] >= 3
|
||||
assert out["stopImmediateCount"] >= 3
|
||||
Reference in new issue
Block a user