Skip to content

Commit ccfeea1

Browse files
Merge pull request #2623 from copse-dev/codex/project-automation-menu
Move automation actions into project row menus
2 parents 981c653 + 110afe3 commit ccfeea1

13 files changed

Lines changed: 172 additions & 100 deletions

‎docs/ui-taste.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -989,9 +989,10 @@ destination. Schedules are project-scoped, so a heading under a project that
989989
isn't open lands on the project first rather than editing another project's
990990
automations. Spec: [`tests/e2e/automation-settings-link.e2e.ts`](../tests/e2e/automation-settings-link.e2e.ts).
991991

992-
Setup now opens the plugin's standalone Automations modal. The side cog exposes
993-
the same list and a direct **New automation…** action; its adjacent Settings label
994-
stays a direct shortcut. Keep the modal header outside its scroll body, retain
992+
Setup now opens the plugin's standalone Automations modal. Each project row's
993+
ellipsis menu exposes **Automations** and **New automation…**, scoped to that row's
994+
project even when another project is active. The sidebar footer keeps only the
995+
direct Settings shortcut. Keep the modal header outside its scroll body, retain
995996
the project scope above the form, and use the identical editor inside Settings.
996997
Do not recreate the editor when plugin enablement changes: it may contain a draft.
997998
Spec: [`tests/e2e/automation-dialog.e2e.ts`](../tests/e2e/automation-dialog.e2e.ts).

‎src/renderer/styles/global/layout.css‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,6 @@
77
height: var(--chrome-action-band-height);
88
border-top: 1px solid var(--border);
99
}
10-
.projects-menu-btn {
11-
display: inline-flex;
12-
align-items: center;
13-
justify-content: center;
14-
width: var(--chrome-action-band-height);
15-
height: 100%;
16-
padding: 0;
17-
color: var(--text-muted);
18-
background: transparent;
19-
border: none;
20-
cursor: pointer;
21-
-webkit-app-region: no-drag;
22-
}
23-
.projects-menu-btn:hover {
24-
color: var(--text-primary);
25-
background: var(--bg-hover);
26-
}
2710
.projects-settings-btn {
2811
display: flex;
2912
align-items: center;
@@ -167,6 +150,7 @@
167150
flex: 1;
168151
min-width: 0;
169152
}
153+
.project-menu-btn,
170154
.project-new-thread-btn {
171155
flex-shrink: 0;
172156
width: var(--projects-action-size);
@@ -176,6 +160,7 @@
176160
line-height: 1;
177161
color: var(--text-secondary);
178162
}
163+
.project-menu-btn:hover,
179164
.project-new-thread-btn:hover {
180165
color: var(--text-primary);
181166
background: var(--bg-hover);

‎src/renderer/views/automation-dialog.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ export function hasAutomationDialog(plugin: PluginSummary): boolean {
2323
export function openAutomationDialog(
2424
store: AppStore,
2525
api: ApiClient,
26-
options: { scheduleId?: string; createNew?: boolean } = {},
26+
options: { projectId?: string; scheduleId?: string; createNew?: boolean } = {},
2727
): void {
2828
if (document.querySelector('#automation-dialog[open]')) return
29-
const projectId = store.getState().activeProjectId
29+
const activeProjectId = store.getState().activeProjectId
30+
const projectId = options.projectId ?? activeProjectId
3031
const { dialog, open, close } = createOverlayDialog({ id: 'automation-dialog' })
3132
dialog.setAttribute('aria-labelledby', 'automation-dialog-title')
3233
const closeButton = el(
@@ -51,7 +52,7 @@ export function openAutomationDialog(
5152
dialog.append(header, body)
5253
// Do not let a project change behind the modal redirect an in-progress edit.
5354
const unsubscribe = store.on('workspace_changed', () => {
54-
if (store.getState().activeProjectId !== projectId) close()
55+
if (store.getState().activeProjectId !== activeProjectId) close()
5556
})
5657
dialog.addEventListener(
5758
'close',
@@ -72,6 +73,7 @@ export function openAutomationDialog(
7273
enabled,
7374
options.scheduleId,
7475
options.createNew,
76+
projectId,
7577
)
7678
const toggle = el(
7779
'button',

‎src/renderer/views/automation-plugin-settings.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ export function createAutomationPluginSettings(
5151
pluginEnabled: boolean,
5252
revealScheduleId?: string,
5353
createNew = false,
54+
projectId = store.getState().activeProjectId,
5455
): AutomationEditor {
5556
const root = el('section', {
5657
class: 'automation-plugin-settings',
5758
'data-plugin-detail': AUTOMATIONS_PLUGIN_ID,
5859
})
59-
const projectId = store.getState().activeProjectId
6060
const project = store.getState().projects.find((candidate) => candidate.id === projectId)
6161

6262
const heading = el('div', { class: 'automation-plugin-heading' })

‎src/renderer/views/projects-pane-automations.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createFakeApi, createPendingApi } from '../fake-api.test-support.ts'
99
import { resetProjectSwitchStateForTest } from '../controller/projects.ts'
1010
import { resetAttention, setAttentionThreads } from '../controller/attention.ts'
1111
import { mountProjectsPane } from './projects-pane.ts'
12+
import { dismissContextMenu } from '../dom/context-menu.ts'
1213
import {
1314
closeSettingsDialog,
1415
isSettingsDialogOpen,
@@ -121,6 +122,7 @@ function mountWithSettings(threads: Thread[], activeThreadId: string): HTMLEleme
121122
}
122123

123124
afterEach(() => {
125+
dismissContextMenu()
124126
if (isSettingsDialogOpen()) closeSettingsDialog()
125127
document.querySelector<HTMLDialogElement>('#automation-dialog')?.close()
126128
document.body.replaceChildren()
@@ -339,3 +341,69 @@ describe('projects pane automation setup links', () => {
339341
assert.deepEqual(labelsFor(conversationRow), ['Rename', 'Fork', 'Archive'])
340342
})
341343
})
344+
345+
describe('project row automation menu', () => {
346+
for (const label of ['Automations', 'New automation…']) {
347+
it(`opens ${label} for the selected row without changing the active project`, async () => {
348+
const store = createStore({
349+
projects: [
350+
{ id: 'a', path: '/a', name: 'Alpha' },
351+
{ id: 'b', path: '/b', name: 'Beta' },
352+
],
353+
activeProjectId: 'a',
354+
expandedProjectId: 'a',
355+
workspaceRoot: '/a',
356+
})
357+
const requestedProjects: string[] = []
358+
const api = createFakeApi()
359+
api.plugins.list = (): Promise<{ plugins: PluginSummary[] }> =>
360+
Promise.resolve({ plugins: [automationsPlugin] })
361+
api.automations.list = (projectId): Promise<AutomationSchedule[]> => {
362+
requestedProjects.push(projectId)
363+
return Promise.resolve([])
364+
}
365+
const host = document.createElement('div')
366+
document.body.append(host)
367+
const dispose = mountProjectsPane(host, store, api)
368+
assert.equal(host.querySelector('.projects-menu-btn'), null)
369+
assert.equal(host.querySelectorAll('.project-line .project-menu-btn').length, 2)
370+
const button = host.querySelector<HTMLButtonElement>(
371+
'.project-entry[data-project-id="b"] .project-menu-btn',
372+
)
373+
assert.ok(button)
374+
button.click()
375+
await new Promise((resolve) => setTimeout(resolve, 0))
376+
const menuItems = Array.from(
377+
document.querySelectorAll<HTMLButtonElement>('.context-menu-item'),
378+
)
379+
assert.ok(menuItems.some((item) => item.textContent === 'Remove from sidebar'))
380+
const action = menuItems.find((item) => item.textContent === label)
381+
assert.ok(action)
382+
action.click()
383+
await new Promise((resolve) => setTimeout(resolve, 0))
384+
const dialog = document.querySelector<HTMLDialogElement>('#automation-dialog')
385+
assert.ok(dialog?.open)
386+
assert.match(dialog.querySelector('.automation-scope')?.textContent ?? '', /Project: Beta/)
387+
assert.deepEqual(requestedProjects, ['b'])
388+
assert.equal(store.getState().activeProjectId, 'a')
389+
assert.equal(store.getState().expandedProjectId, 'a')
390+
assert.equal(
391+
dialog.querySelector<HTMLFormElement>('.automation-form')?.hidden,
392+
label !== 'New automation…',
393+
)
394+
dispose()
395+
})
396+
}
397+
398+
it('keeps project actions available without the automation plugin', async () => {
399+
const host = mount([], '')
400+
host.querySelector<HTMLButtonElement>('.project-menu-btn')?.click()
401+
await new Promise((resolve) => setTimeout(resolve, 0))
402+
const labels = Array.from(document.querySelectorAll('.context-menu-item')).map(
403+
(item) => item.textContent,
404+
)
405+
assert.ok(labels.includes('Remove from sidebar'))
406+
assert.ok(!labels.includes('Automations'))
407+
assert.ok(!labels.includes('New automation…'))
408+
})
409+
})

‎src/renderer/views/projects-pane-drag.test.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,8 @@ describe('projects pane — drag to reorder (component)', () => {
213213
it('tags every row with the id that addresses it', () => {
214214
// `beginGroupRename` re-queries by these after a re-render, and the e2e
215215
// drag helper (tests/e2e/helpers/sidebar-drag.ts) addresses rows by them
216-
// rather than adding a selector that only tests use. An expanded project
217-
// wraps its row in `.project-line`, a collapsed one does not — both shapes
218-
// have to stay reachable.
216+
// rather than adding a selector that only tests use. Expanded and collapsed
217+
// projects both wrap their row and menu button in `.project-line`.
219218
mount(
220219
[project('a', 'Alpha'), project('b', 'Beta'), project('c', 'Gamma', 'work')],
221220
[{ id: 'work', name: 'Work' }],

‎src/renderer/views/projects-pane.ts‎

Lines changed: 61 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
chevronRightIcon,
66
closeIcon,
77
gitPullRequestIcon,
8+
moreHorizontalIcon,
89
plusIcon,
910
runningStatusIcon,
1011
searchIcon,
@@ -251,62 +252,11 @@ export function mountProjectsPane(root: HTMLElement, store: AppStore, api: ApiCl
251252
settingsBtn.addEventListener('click', () => {
252253
openSettingsDialog()
253254
})
254-
const menuButton = el(
255-
'button',
256-
{
257-
type: 'button',
258-
class: 'projects-menu-btn',
259-
'aria-label': 'Project menu',
260-
'aria-haspopup': 'menu',
261-
'data-tooltip': 'Settings and automations',
262-
},
263-
settingsIcon(),
264-
)
265-
menuButton.addEventListener('click', () => {
266-
menuButton.disabled = true
267-
void api.plugins
268-
.list()
269-
.then((result) => {
270-
const rect = menuButton.getBoundingClientRect()
271-
const plugin = result.plugins.find(hasAutomationDialog)
272-
showContextMenu(rect.left, rect.top, [
273-
{
274-
label: 'Settings',
275-
onSelect: (): void => {
276-
openSettingsDialog()
277-
},
278-
},
279-
...(plugin
280-
? [
281-
{
282-
label: 'Automations',
283-
onSelect: (): void => {
284-
openAutomationDialog(store, api)
285-
},
286-
},
287-
{
288-
label: 'New automation…',
289-
disabled: !store.getState().activeProjectId,
290-
onSelect: (): void => {
291-
openAutomationDialog(store, api, { createNew: true })
292-
},
293-
},
294-
]
295-
: []),
296-
])
297-
})
298-
.catch((error: unknown) => {
299-
showErrorToast('Could not load project menu', error)
300-
})
301-
.finally(() => {
302-
menuButton.disabled = false
303-
})
304-
})
305255
root.append(
306256
header,
307257
searchRow,
308258
list,
309-
el('div', { class: 'projects-settings-actions' }, menuButton, settingsBtn),
259+
el('div', { class: 'projects-settings-actions' }, settingsBtn),
310260
)
311261

312262
let sshWorkspaceEnabled = false
@@ -889,27 +839,74 @@ export function mountProjectsPane(root: HTMLElement, store: AppStore, api: ApiCl
889839
}
890840
switchProject(store, api, project.id)
891841
})
842+
const projectMenuEntries: ContextMenuEntry[] = [
843+
{
844+
label: 'Remove from sidebar',
845+
onSelect: (): void => {
846+
void removeProject(store, api, project.id)
847+
},
848+
},
849+
...groupMenuEntries(project, projectGroups),
850+
]
892851
projectRow.addEventListener('contextmenu', (e) => {
893852
e.preventDefault()
894853
e.stopPropagation()
895-
showContextMenu(e.clientX, e.clientY, [
896-
{
897-
label: 'Remove from sidebar',
898-
onSelect: (): void => {
899-
void removeProject(store, api, project.id)
900-
},
901-
},
902-
...groupMenuEntries(project, projectGroups),
903-
])
854+
showContextMenu(e.clientX, e.clientY, projectMenuEntries)
855+
})
856+
const menuButton = el(
857+
'button',
858+
{
859+
type: 'button',
860+
class: 'project-menu-btn',
861+
'aria-label': `Project menu for ${projectDisplayName(project)}`,
862+
'aria-haspopup': 'menu',
863+
'data-tooltip': 'Project menu',
864+
},
865+
moreHorizontalIcon('ui-icon ui-icon-sm'),
866+
)
867+
menuButton.addEventListener('click', () => {
868+
menuButton.disabled = true
869+
void api.plugins
870+
.list()
871+
.then((result) => {
872+
if (!menuButton.isConnected) return
873+
const rect = menuButton.getBoundingClientRect()
874+
const entries: ContextMenuEntry[] = []
875+
if (result.plugins.some(hasAutomationDialog)) {
876+
entries.push(
877+
{
878+
label: 'Automations',
879+
onSelect: (): void => {
880+
openAutomationDialog(store, api, { projectId: project.id })
881+
},
882+
},
883+
{
884+
label: 'New automation…',
885+
disabled: project.missing === true,
886+
onSelect: (): void => {
887+
openAutomationDialog(store, api, { projectId: project.id, createNew: true })
888+
},
889+
},
890+
)
891+
}
892+
showContextMenu(rect.left, rect.bottom, [...entries, ...projectMenuEntries])
893+
})
894+
.catch((error: unknown) => {
895+
showErrorToast('Could not load project menu', error)
896+
})
897+
.finally(() => {
898+
menuButton.disabled = false
899+
})
904900
})
901+
const projectLine = el('div', { class: 'project-line' }, projectRow, menuButton)
902+
entry.append(projectLine)
905903

906904
if (isExpanded && project.missing) {
907-
entry.append(projectRow, renderMissingNotice(project))
905+
entry.append(renderMissingNotice(project))
908906
return entry
909907
}
910908

911909
if (isExpanded) {
912-
const projectLine = el('div', { class: 'project-line' })
913910
const newThreadBtn = el(
914911
'button',
915912
{
@@ -932,10 +929,7 @@ export function mountProjectsPane(root: HTMLElement, store: AppStore, api: ApiCl
932929
}
933930
openNewThread(store)
934931
})
935-
projectLine.append(projectRow, newThreadBtn)
936-
entry.append(projectLine)
937-
} else {
938-
entry.append(projectRow)
932+
projectLine.append(newThreadBtn)
939933
}
940934

941935
if (!isExpanded) return entry

0 commit comments

Comments
 (0)