diff --git a/static/sessions.js b/static/sessions.js index ec53094f..e75785a9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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 diff --git a/static/style.css b/static/style.css index cd976b27..3cc235c2 100644 --- a/static/style.css +++ b/static/style.css @@ -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 diff --git a/tests/test_4676_project_new_session_shortcuts.py b/tests/test_4676_project_new_session_shortcuts.py new file mode 100644 index 00000000..639a81b7 --- /dev/null +++ b/tests/test_4676_project_new_session_shortcuts.py @@ -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