From 1b9cf427db74f96daec85e8c81d37f0c178a58fb Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 28 May 2026 12:59:47 +0900 Subject: [PATCH 1/4] refactor(ui): rework queue with dedicated views per action type Replace the single-line summary row with a card that renders each action's payload in its native shape: label pills with real colors, user avatars, multi-line bodies, merge method + commit details, reactions with emoji. Per-item entries are no longer hidden, and each card's footer opens its source file in the editor. --- ui/components/hub/QueueList.vue | 32 ++--- ui/components/panel/Queue.vue | 29 +--- ui/components/queue/EntryCard.vue | 207 ++++++++++++++++++++++++++++ ui/components/queue/op/Body.vue | 38 +++++ ui/components/queue/op/Labels.vue | 20 +++ ui/components/queue/op/Merge.vue | 33 +++++ ui/components/queue/op/Reaction.vue | 38 +++++ ui/components/queue/op/Users.vue | 21 +++ ui/composables/useHubQueue.ts | 2 +- ui/composables/useQueue.ts | 5 +- ui/uno.config.ts | 10 ++ ui/utils/actionMeta.ts | 40 ++++++ ui/utils/queueSummary.ts | 33 ----- 13 files changed, 422 insertions(+), 86 deletions(-) create mode 100644 ui/components/queue/EntryCard.vue create mode 100644 ui/components/queue/op/Body.vue create mode 100644 ui/components/queue/op/Labels.vue create mode 100644 ui/components/queue/op/Merge.vue create mode 100644 ui/components/queue/op/Reaction.vue create mode 100644 ui/components/queue/op/Users.vue create mode 100644 ui/utils/actionMeta.ts delete mode 100644 ui/utils/queueSummary.ts diff --git a/ui/components/hub/QueueList.vue b/ui/components/hub/QueueList.vue index e74487a..2eda478 100644 --- a/ui/components/hub/QueueList.vue +++ b/ui/components/hub/QueueList.vue @@ -1,15 +1,13 @@ + + diff --git a/ui/components/queue/op/Body.vue b/ui/components/queue/op/Body.vue new file mode 100644 index 0000000..bfab880 --- /dev/null +++ b/ui/components/queue/op/Body.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/components/queue/op/Labels.vue b/ui/components/queue/op/Labels.vue new file mode 100644 index 0000000..9b84298 --- /dev/null +++ b/ui/components/queue/op/Labels.vue @@ -0,0 +1,20 @@ + + + diff --git a/ui/components/queue/op/Merge.vue b/ui/components/queue/op/Merge.vue new file mode 100644 index 0000000..fec2f46 --- /dev/null +++ b/ui/components/queue/op/Merge.vue @@ -0,0 +1,33 @@ + + + diff --git a/ui/components/queue/op/Reaction.vue b/ui/components/queue/op/Reaction.vue new file mode 100644 index 0000000..16c5d55 --- /dev/null +++ b/ui/components/queue/op/Reaction.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/components/queue/op/Users.vue b/ui/components/queue/op/Users.vue new file mode 100644 index 0000000..0d493f3 --- /dev/null +++ b/ui/components/queue/op/Users.vue @@ -0,0 +1,21 @@ + + + diff --git a/ui/composables/useHubQueue.ts b/ui/composables/useHubQueue.ts index 5e30089..5a02ef7 100644 --- a/ui/composables/useHubQueue.ts +++ b/ui/composables/useHubQueue.ts @@ -10,7 +10,7 @@ const executing = ref(null) let installed = false function totalFor(g: HubQueueGroup): number { - return g.queue.entries.filter(e => e.source !== 'per-item').length + return g.queue.entries.length } export function useHubQueue() { diff --git a/ui/composables/useQueue.ts b/ui/composables/useQueue.ts index 338de43..0d8d6c4 100644 --- a/ui/composables/useQueue.ts +++ b/ui/composables/useQueue.ts @@ -5,10 +5,7 @@ import { useAppState } from './useAppState' export function useQueue() { const state = useAppState() - const entries = computed(() => { - const all = state.payload.value?.queue.entries ?? [] - return all.filter(e => e.source !== 'per-item') - }) + const entries = computed(() => state.payload.value?.queue.entries ?? []) const upCount = computed(() => entries.value.length) diff --git a/ui/uno.config.ts b/ui/uno.config.ts index 519e501..6580d9e 100644 --- a/ui/uno.config.ts +++ b/ui/uno.config.ts @@ -181,5 +181,15 @@ export default defineConfig({ 'i-octicon-alert-fill-16', 'i-octicon-x-16', 'i-octicon-play-16', + // Queue card action icons (ui/utils/actionMeta.ts) + source/meta icons + 'i-octicon-check-16', + 'i-octicon-unlock-16', + 'i-octicon-person-add-16', + 'i-octicon-git-merge-queue-16', + 'i-octicon-smiley-16', + 'i-octicon-file-code-16', + 'i-octicon-markdown-16', + 'i-octicon-clock-16', + 'i-octicon-stack-16', ], }) diff --git a/ui/utils/actionMeta.ts b/ui/utils/actionMeta.ts new file mode 100644 index 0000000..cefabca --- /dev/null +++ b/ui/utils/actionMeta.ts @@ -0,0 +1,40 @@ +import type { ActionName } from '#ghfs/action-colors' + +export interface ActionMeta { + label: string + icon: string +} + +export const ACTION_META: Record = { + 'close': { label: 'Close', icon: 'i-octicon-issue-closed-16' }, + 'close-with-comment': { label: 'Close with comment', icon: 'i-octicon-issue-closed-16' }, + 'reopen': { label: 'Reopen', icon: 'i-octicon-issue-reopened-16' }, + 'set-title': { label: 'Set title', icon: 'i-octicon-pencil-16' }, + 'set-body': { label: 'Set body', icon: 'i-octicon-pencil-16' }, + 'add-comment': { label: 'Add comment', icon: 'i-octicon-comment-16' }, + 'add-labels': { label: 'Add labels', icon: 'i-octicon-tag-16' }, + 'remove-labels': { label: 'Remove labels', icon: 'i-octicon-tag-16' }, + 'set-labels': { label: 'Set labels', icon: 'i-octicon-tag-16' }, + 'add-assignees': { label: 'Add assignees', icon: 'i-octicon-person-add-16' }, + 'remove-assignees': { label: 'Remove assignees', icon: 'i-octicon-person-16' }, + 'set-assignees': { label: 'Set assignees', icon: 'i-octicon-person-16' }, + 'set-milestone': { label: 'Set milestone', icon: 'i-octicon-milestone-16' }, + 'clear-milestone': { label: 'Clear milestone', icon: 'i-octicon-milestone-16' }, + 'lock': { label: 'Lock', icon: 'i-octicon-lock-16' }, + 'unlock': { label: 'Unlock', icon: 'i-octicon-unlock-16' }, + 'request-reviewers': { label: 'Request reviewers', icon: 'i-octicon-eye-16' }, + 'remove-reviewers': { label: 'Remove reviewers', icon: 'i-octicon-eye-16' }, + 'mark-ready-for-review': { label: 'Mark ready for review', icon: 'i-octicon-git-pull-request-16' }, + 'convert-to-draft': { label: 'Convert to draft', icon: 'i-octicon-git-pull-request-draft-16' }, + 'approve': { label: 'Approve', icon: 'i-octicon-check-16' }, + 'request-changes': { label: 'Request changes', icon: 'i-octicon-x-16' }, + 'review-comment': { label: 'Review comment', icon: 'i-octicon-comment-discussion-16' }, + 'merge': { label: 'Merge', icon: 'i-octicon-git-merge-16' }, + 'enqueue-merge': { label: 'Enqueue for merge', icon: 'i-octicon-git-merge-queue-16' }, + 'add-reaction': { label: 'Add reaction', icon: 'i-octicon-smiley-16' }, + 'remove-reaction': { label: 'Remove reaction', icon: 'i-octicon-smiley-16' }, +} + +export function actionMeta(action: ActionName | string): ActionMeta { + return ACTION_META[action as ActionName] ?? { label: action, icon: 'i-octicon-dot-16' } +} diff --git a/ui/utils/queueSummary.ts b/ui/utils/queueSummary.ts deleted file mode 100644 index 88f8c8f..0000000 --- a/ui/utils/queueSummary.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { REACTION_EMOJI } from '../../src/utils/reactions' - -export function summarizeQueueOp(op: Record): string { - const details: string[] = [] - if ('labels' in op && Array.isArray(op.labels)) - details.push((op.labels as string[]).join(', ')) - if ('assignees' in op && Array.isArray(op.assignees)) - details.push((op.assignees as string[]).map(a => `@${a}`).join(', ')) - if ('reviewers' in op && Array.isArray(op.reviewers)) - details.push((op.reviewers as string[]).map(r => `@${r}`).join(', ')) - if ('title' in op && typeof op.title === 'string') - details.push(`"${op.title}"`) - if ('body' in op && typeof op.body === 'string') - details.push(`"${op.body.slice(0, 60)}${op.body.length > 60 ? '…' : ''}"`) - if ('milestone' in op && op.milestone != null) - details.push(String(op.milestone)) - if (op.action === 'merge') - details.push(`(${(op.method as string) ?? 'squash'})`) - if (op.action === 'enqueue-merge') - details.push('(when ready)') - if ('reaction' in op && typeof op.reaction === 'string') { - const emoji = REACTION_EMOJI[op.reaction as keyof typeof REACTION_EMOJI] - details.push(emoji ? `${emoji} ${op.reaction}` : op.reaction) - } - if ('target' in op && op.target && typeof op.target === 'object') { - const t = op.target as { kind?: string, commentId?: number, reviewId?: string } - if (t.kind === 'comment' && t.commentId != null) - details.push(`on comment ${t.commentId}`) - else if (t.kind === 'review' && t.reviewId) - details.push('on review') - } - return details.join(' ') -} From 5d018d7f43b7b002c5b7b0da3574274766f9b61b Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 28 May 2026 13:41:03 +0900 Subject: [PATCH 2/4] refactor(ui): condense queue card header and show item title Move the open-source affordance to a small icon button in the header (the icon already encodes the source type), and drop the dedicated footer. Surface each entry's issue/PR title next to the number so hub-level entries are recognisable at a glance. --- src/server/queue-builder.ts | 10 ++++--- src/server/types.ts | 2 ++ ui/components/queue/EntryCard.vue | 44 ++++++++++++++----------------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/server/queue-builder.ts b/src/server/queue-builder.ts index 8deba82..77dd71a 100644 --- a/src/server/queue-builder.ts +++ b/src/server/queue-builder.ts @@ -25,13 +25,14 @@ export async function buildQueueState(options: BuildQueueStateOptions): Promise< ]) const entries: QueueEntry[] = [] + const titleOf = (op: PendingOp): string | undefined => syncState.items[String(op.number)]?.data.item.title for (const [index, op] of yml.ops.entries()) - entries.push(buildEntry(op, 'execute.yml', index)) + entries.push(buildEntry(op, 'execute.yml', index, undefined, titleOf(op))) for (const [index, op] of md.ops.entries()) - entries.push(buildEntry(op, 'execute.md', index)) + entries.push(buildEntry(op, 'execute.md', index, undefined, titleOf(op))) for (const [index, op] of perItem.ops.entries()) { const filePath = syncState.items[String(op.number)]?.filePath - entries.push(buildEntry(op, 'per-item', index, filePath)) + entries.push(buildEntry(op, 'per-item', index, filePath, titleOf(op))) } return { @@ -58,12 +59,13 @@ async function readMdOps(path: string): Promise<{ ops: PendingOp[], warnings: st return { ops: parsed.ops, warnings: parsed.warnings } } -function buildEntry(op: PendingOp, source: QueueSource, index: number, filePath?: string): QueueEntry { +function buildEntry(op: PendingOp, source: QueueSource, index: number, filePath?: string, title?: string): QueueEntry { return { id: hash({ source, index, action: op.action, number: op.number }), source, index, op, filePath, + title, } } diff --git a/src/server/types.ts b/src/server/types.ts index ab0a1e8..8db215b 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -23,6 +23,8 @@ export interface QueueEntry { op: PendingOp /** Relative file path under the storage directory, for per-item entries. */ filePath?: string + /** Title of the target issue/PR, looked up from syncState. Absent if the item isn't synced. */ + title?: string } export interface QueueState { diff --git a/ui/components/queue/EntryCard.vue b/ui/components/queue/EntryCard.vue index 419bf6a..e44eb05 100644 --- a/ui/components/queue/EntryCard.vue +++ b/ui/components/queue/EntryCard.vue @@ -32,11 +32,6 @@ const op = computed(() => props.entry.op) const meta = computed(() => actionMeta(op.value.action)) const accent = computed(() => (ACTIONS_COLOR_HEX as Record)[op.value.action] ?? '#6b7280') -const SOURCE_LABEL: Record = { - 'execute.yml': 'execute.yml', - 'execute.md': 'execute.md', - 'per-item': 'per-item', -} const SOURCE_ICON: Record = { 'execute.yml': 'i-octicon-file-code-16', 'execute.md': 'i-octicon-markdown-16', @@ -98,19 +93,34 @@ async function openSource() { {{ meta.label }} - #{{ op.number }} + class="flex items-center gap-1.5 min-w-0 flex-1" + > + #{{ op.number }} + {{ entry.title }} + +
-
+
-
- - - {{ SOURCE_LABEL[entry.source] }} - - -
From 4d38b1cd2613c866b3da8bb09f38dd378ce7789f Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 28 May 2026 13:54:40 +0900 Subject: [PATCH 3/4] fix(ui): render queue entry title even when number is hidden Show the issue/PR title in the entry card whenever syncState knows it, not only when the number is being rendered. Adds a regression test that queue-builder propagates titles from syncState into yml entries. --- src/server/queue-builder.test.ts | 48 +++++++++++++++++++++++++++++++ ui/components/queue/EntryCard.vue | 11 ++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/server/queue-builder.test.ts b/src/server/queue-builder.test.ts index 2656b07..656818f 100644 --- a/src/server/queue-builder.test.ts +++ b/src/server/queue-builder.test.ts @@ -114,6 +114,54 @@ describe('buildQueueState', () => { expect(perItem).toBeDefined() expect(perItem?.op).toMatchObject({ action: 'close', number: 5 }) expect(perItem?.filePath).toBe(filePath) + expect(perItem?.title).toBe('Sample') + }) + + it('populates entry.title from syncState for yml entries', async () => { + const dir = await createTempDir() + await writeFile(join(dir, 'execute.yml'), '- action: close\n number: 7\n', 'utf8') + + await saveSyncState(dir, { + version: 2, + repo: 'owner/repo', + items: { + 7: { + number: 7, + kind: 'issue', + state: 'open', + lastUpdatedAt: '2026-01-01T00:00:00Z', + lastSyncedAt: '2026-01-01T00:00:00Z', + filePath: 'issues/00007-needs-cleanup.md', + data: { + item: { + number: 7, + kind: 'issue', + state: 'open', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + closedAt: null, + title: 'Needs cleanup', + body: null, + author: 'alice', + labels: [], + assignees: [], + milestone: null, + }, + comments: [], + }, + }, + }, + executions: [], + }) + + const queue = await buildQueueState({ + storageDirAbsolute: dir, + executeFilePath: join(dir, 'execute.yml'), + }) + + const entry = queue.entries.find(e => e.op.number === 7) + expect(entry).toBeDefined() + expect(entry?.title).toBe('Needs cleanup') }) it('assigns stable ids based on content and source', async () => { diff --git a/ui/components/queue/EntryCard.vue b/ui/components/queue/EntryCard.vue index e44eb05..d9330ba 100644 --- a/ui/components/queue/EntryCard.vue +++ b/ui/components/queue/EntryCard.vue @@ -93,17 +93,16 @@ async function openSource() { {{ meta.label }} -
- #{{ op.number }} +
+ #{{ op.number }} {{ entry.title }}
-
Date: Thu, 28 May 2026 14:00:31 +0900 Subject: [PATCH 4/4] fix(ui): guard detail tabs against transient null item When navigating between items the `item` computed briefly becomes null before the v-if/v-else swaps in the empty state. Reka-ui's force-mounted TabsContent re-rendered children during that window, which crashed DetailConversationTab on item.author. Guard the tab bodies with v-if="item". --- ui/components/panel/Detail.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/components/panel/Detail.vue b/ui/components/panel/Detail.vue index fdc32ed..7329e08 100644 --- a/ui/components/panel/Detail.vue +++ b/ui/components/panel/Detail.vue @@ -414,6 +414,7 @@ async function discardThisItem() {
- +