feat(projects): add per-project new conversation shortcuts (#4676)

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-30 19:28:27 +00:00
1 parent 1ba91d9e6b
commit 11882aab2a
3 files changed
+381 -1

No files matched your search

+28 -1
View File
@@ -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
+41
View File
@@ -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