Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/main/ipc/register-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,22 @@ export function registerAllHandlers(win: BrowserWindow, registry: ToolRegistry):
return importIssuesAsRoadmapItems(issues, undefined, undefined, notifyRoadmapChanged)
})

// Track the chat thread started from an item ("Start thread" in the pane) in
// a `thread` frontmatter field, so the pane can offer reopening it later.
// Restamping is deliberate: starting a fresh thread from the same item points
// the field at the newest one. An empty threadId clears the tracking.
ipcMain.handle('roadmap:setThread', (event, rawId: unknown, rawThreadId: unknown) => {
assertMainFrameSender(event, win)
const id = parseIpcArgs(zRoadmapId, [rawId])
const threadId = parseIpcArgs(z.string().max(128).optional(), [rawThreadId])?.trim() ?? ''
const existing = getKnowledgeNote(id)
if (!existing || existing.type !== ROADMAP_TYPE) return null
const { thread: _thread, ...rest } = existing.fields
return updateKnowledgeNote(id, {
fields: { ...rest, ...(threadId ? { thread: threadId } : {}) },
})
})

// Advisory fit check of an item's prompt against its pinned issue,
// explicitly triggered from the pane (see roadmap-fit-check.ts).
ipcMain.handle('roadmap:checkFit', (event, rawId: unknown) => {
Expand Down
4 changes: 4 additions & 0 deletions src/preload/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,10 @@ export interface ApiClient {
/** Subscribe to background roadmap changes (e.g. a complexity stamp landing
* after a save returned). Returns an unsubscribe function. */
onChanged: (handler: () => void) => () => void
setThread: (
id: string,
threadId: string,
) => Promise<import('../main/services/storage/knowledge-store.ts').KnowledgeNote | null>
}
skills: {
list: () => Promise<SkillSummary[]>
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,8 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.off('roadmap:changed', listener)
}
},
setThread: (id: string, threadId: string) =>
ipcRenderer.invoke('roadmap:setThread', id, threadId),
},
skills: {
list: () => ipcRenderer.invoke('skills:list'),
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/styles/global/popout.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ html.is-popout .pane-popout-btn {
display: none;
}

/* A detached Roadmap window has no chat pane for a new thread to land in. */
html.is-popout .roadmap-start-btn {
/* A detached Roadmap window has no chat pane for a thread to land in — hide
both starting a new thread and reopening a tracked one. */
html.is-popout .roadmap-start-btn,
html.is-popout .roadmap-reopen-btn,
html.is-popout .roadmap-thread-chip {
display: none;
}
28 changes: 28 additions & 0 deletions src/renderer/styles/global/roadmap.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@
width: auto;
align-self: flex-start;
}
/* The roadmap has two thread actions (Start + Reopen) in addition to
Save/Delete/Cancel. A compact three-column grid keeps every action inside
the fixed-width editor column instead of clipping the trailing button. */
.roadmap-form .memories-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.roadmap-form .memories-actions .memories-btn {
min-width: 0;
padding-inline: var(--spacing-sm);
}
.roadmap-form .memories-actions .memories-btn-danger {
margin-left: 0;
}
.roadmap-row-meta {
display: flex;
flex-wrap: wrap;
Expand All @@ -84,6 +98,20 @@
.roadmap-issue-chip:hover {
text-decoration: underline;
}
/* Started-thread chip — shown while the thread tracked on the item still
exists; clicking it reopens that thread. */
.roadmap-thread-chip {
font-size: 10px;
line-height: 1.4;
padding: 0 6px;
border-radius: var(--radius);
background: var(--bg-hover);
color: var(--accent);
cursor: pointer;
}
.roadmap-thread-chip:hover {
text-decoration: underline;
}

/* Complexity badge — stamped on save by the small-tasks model (or heuristic). */
.roadmap-complexity-badge {
Expand Down
125 changes: 125 additions & 0 deletions src/renderer/views/roadmap-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '../../../tests/setup-dom.ts'
import { afterEach, beforeEach, describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createStore } from '@shared/store/store.ts'
import type { Thread } from '@shared/types'
import type { ApiClient } from '../../preload/api.d.ts'
import { registerPromptAttachments } from '../attachments/prompt-attachments.ts'
import { mountRoadmapPane } from './roadmap-pane.ts'
Expand Down Expand Up @@ -51,6 +52,19 @@ interface MockAttachmentAdd {
dataUrl: string
}

/** Minimal persisted thread the pane's reopen path can resolve by id. */
function makeThread(id: string, title: string): Thread {
return {
id,
title,
status: 'idle',
messages: [],
usage: { inputTokens: 0, outputTokens: 0 },
createdAt: Date.now(),
updatedAt: Date.now(),
}
}

interface RoadmapCalls {
list: number
// `attachments` / `addAttachments` / `removeAttachmentIds` are recorded only
Expand Down Expand Up @@ -79,6 +93,7 @@ interface RoadmapCalls {
openIssues: number
importIssues: { number: number; title: string; body: string }[][]
checkFit: string[]
setThread: { id: string; threadId: string }[]
}

interface MockOpenIssue {
Expand Down Expand Up @@ -127,6 +142,7 @@ function makeApi(
openIssues: 0,
importIssues: [],
checkFit: [],
setThread: [],
}
const api = {
panes: { popout: async (): Promise<void> => {} },
Expand Down Expand Up @@ -224,6 +240,14 @@ function makeApi(
}
return { verdict: 'partial', detail: '- prompt does not mention the startup flash' }
},
setThread: async (id: string, threadId: string) => {
calls.setThread.push({ id, threadId })
const item = items.find((i) => i.id === id)
if (!item) return null
const { thread: _thread, ...rest } = item.fields
item.fields = { ...rest, ...(threadId ? { thread: threadId } : {}) }
return { ...item }
},
importIssues: async (selected: { number: number; title: string; body: string }[]) => {
calls.importIssues.push(selected)
const created = selected.map((s) =>
Expand Down Expand Up @@ -668,6 +692,107 @@ describe('roadmap pane', () => {
}
})

it('records the started thread on the item and then offers Reopen', async () => {
const store = createStore({ filesPaneOpen: true, rightPanelMode: 'roadmap' })
const { api, calls } = makeApi([makeItem('a', 'Ship the thing')])
const { list, viewer } = mountHosts()
const unmount = mountRoadmapPane(list, viewer, store, api)
try {
await flush()
list.querySelector<HTMLButtonElement>('.roadmap-row')?.click()
const reopenBtn = viewer.querySelector<HTMLButtonElement>('.roadmap-reopen-btn')
assert.ok(reopenBtn)
assert.equal(reopenBtn.hidden, true, 'nothing to reopen before a thread starts')
viewer.querySelector<HTMLButtonElement>('.roadmap-start-btn')?.click()
await flush()
const threadId = store.getState().activeThreadId
assert.ok(threadId)
assert.deepEqual(calls.setThread, [{ id: 'a', threadId }], 'stamps the new thread id')
assert.equal(reopenBtn.hidden, false, 'the tracked thread can now be reopened')
assert.ok(list.querySelector('.roadmap-thread-chip'), 'the row shows a thread chip')
} finally {
unmount()
}
})

it('reopens the tracked thread instead of creating a new one', async () => {
const tracked = makeThread('t1', 'Ship the thing')
const other = makeThread('t2', 'Something else')
// A blank idle thread is pruned on switch-away; a draft keeps it alive so
// the thread count stays comparable.
other.draftPrompt = 'wip draft'
const store = createStore({
filesPaneOpen: true,
rightPanelMode: 'roadmap',
threads: [tracked, other],
activeThreadId: 't2',
})
const seeded = makeItem('a', 'Ship the thing')
seeded.fields['thread'] = 't1'
const { api } = makeApi([seeded])
const { list, viewer } = mountHosts()
const unmount = mountRoadmapPane(list, viewer, store, api)
try {
await flush()
list.querySelector<HTMLButtonElement>('.roadmap-row')?.click()
const reopenBtn = viewer.querySelector<HTMLButtonElement>('.roadmap-reopen-btn')
assert.ok(reopenBtn)
assert.equal(reopenBtn.hidden, false)
let opened = 0
store.on('new_thread_opened', () => opened++)
reopenBtn.click()
assert.equal(store.getState().activeThreadId, 't1', 'switches to the tracked thread')
assert.equal(opened, 0, 'does not open a new thread')
assert.equal(store.getState().threads.length, 2, 'no thread was created')
} finally {
unmount()
}
})

it('hides Reopen and the row chip when the tracked thread no longer exists', async () => {
const store = createStore({ filesPaneOpen: true, rightPanelMode: 'roadmap' })
const seeded = makeItem('a', 'Ship the thing')
seeded.fields['thread'] = 'deleted-thread'
const { api } = makeApi([seeded])
const { list, viewer } = mountHosts()
const unmount = mountRoadmapPane(list, viewer, store, api)
try {
await flush()
assert.equal(list.querySelector('.roadmap-thread-chip'), null)
list.querySelector<HTMLButtonElement>('.roadmap-row')?.click()
assert.equal(viewer.querySelector<HTMLButtonElement>('.roadmap-reopen-btn')?.hidden, true)
} finally {
unmount()
}
})

it('reopens the tracked thread from the row chip without selecting the item', async () => {
const tracked = makeThread('t1', 'Ship the thing')
const store = createStore({
filesPaneOpen: true,
rightPanelMode: 'roadmap',
threads: [tracked, makeThread('t2', 'Current')],
activeThreadId: 't2',
})
const seeded = makeItem('a', 'Ship the thing')
seeded.fields['thread'] = 't1'
const { api } = makeApi([seeded])
const { list, viewer } = mountHosts()
const unmount = mountRoadmapPane(list, viewer, store, api)
try {
await flush()
const chip = list.querySelector<HTMLElement>('.roadmap-thread-chip')
assert.ok(chip)
assert.equal(chip.tabIndex, 0, 'the nested row link must be keyboard focusable')
chip.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
assert.equal(store.getState().activeThreadId, 't1')
// Activating the chip must not select the row into the editor.
assert.equal(viewer.querySelector<HTMLElement>('.roadmap-empty')?.hidden, false)
} finally {
unmount()
}
})

it('hides the start-thread button on a blank new-item form', async () => {
const store = createStore({ filesPaneOpen: true, rightPanelMode: 'roadmap' })
const { api } = makeApi([])
Expand Down
Loading
Loading