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
14 changes: 11 additions & 3 deletions desktop/e2e/tests/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,19 @@ actions:
applies_to: [pr]
repo_template: "https://github.com/{{ .Payload.repo }}.git"
prompt_template: |
Review pull request {{ .Payload.url }}: {{ .Payload.title }}
Review pull request {{ .Payload.title }}

{{ .Payload.body }}
{{ .Payload.url }}
- id: start-implementation
label: Start implementation
type: launch-session
show_in_detail: true
applies_to: [issue]
repo_template: "https://github.com/{{ .Payload.repo }}.git"
prompt_template: |
Implement issue {{ .Payload.url }}: {{ .Payload.title }}
Work on {{ .Payload.title }}

{{ .Payload.url }}

{{ .Payload.body }}
`)
Expand Down Expand Up @@ -117,6 +119,12 @@ test('persists shell output, failure diagnostics, and durable duplicate rejectio
const shell = action(state.runId, 'pr')
await page.locator(`[data-id="${shell}"]`).click()
await expect(page.getByTestId('toast')).toContainText('Smoke PR completed')
const jobsChip = page.getByTestId('titlebar-jobs')
await expect(jobsChip).toBeVisible()
await jobsChip.click()
await expect(page.getByTestId('jobs-popover')).toContainText('Smoke PR')
await expect(page.getByTestId('jobs-popover')).toContainText('Completed')
await expect(jobsChip).toBeHidden({ timeout: 7_000 })
await expect.poll(async () => (await smoke(page)).outputCommands.filter((command) => command.actionId === shell)).toEqual([
expect.objectContaining({ key: 'pr2841', status: 'done', stdout: 'smoke-stdout', stderr: 'smoke-stderr', lastError: '' }),
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import * as ActionsService from "./actionsservice.js";
import * as ActivityService from "./activityservice.js";
import * as FlowsService from "./flowsservice.js";
import * as JobService from "./jobservice.js";
import * as PipelineService from "./pipelineservice.js";
import * as SettingsService from "./settingsservice.js";
import * as SystemService from "./systemservice.js";
export {
ActionsService,
ActivityService,
FlowsService,
JobService,
PipelineService,
SettingsService,
SystemService
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* JobService exposes live action-run jobs to the desktop frontend. The
* titlebar reads active and briefly lingering jobs through ListActive, while
* List provides cursor-based history paging.
* @module
*/

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as jobs$0 from "../internal/desktop/jobs/models.js";

/**
* List returns up to limit jobs with id < before, newest first.
*/
export function List(before: number, limit: number): $CancellablePromise<jobs$0.Job[] | null> {
return $Call.ByID(1946050826, before, limit);
}

/**
* ListActive returns non-terminal jobs plus terminal jobs completed within the
* backend-owned lingering window.
*/
export function ListActive(): $CancellablePromise<jobs$0.Job[] | null> {
return $Call.ByID(2667553212);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

export type {
Job,
JobStatus
} from "./models.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* Job is one action-run lifecycle row. ID, CreatedAt, and UpdatedAt are
* assigned by the store. CreatedAt and UpdatedAt are unix milliseconds. Step
* is a human label derived from Status in v1.
*/
export interface Job {
"id": number;
"createdAt": number;
"updatedAt": number;
"status": JobStatus;
"label": string;
"step": string;
"actionId": string;
"target": string;
"error"?: string;
"commandId"?: number | null;
}

/**
* JobStatus is a job's lifecycle stage.
*
* ENUM(queued, running, done, failed)
*/
export type JobStatus = string;
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ declare module "@wailsio/runtime" {
"activity:appended": number;
"auth:updated": string;
"flows:updated": string;
"jobs:updated": string;
"log:appended": number;
}
}
Expand Down
16 changes: 15 additions & 1 deletion desktop/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import ToastStack from './components/ToastStack.vue'
import DevBar from './components/DevBar.vue'
import { useAuth } from './composables/useAuth'
import { useActivity } from './composables/useActivity'
import { useJobs } from './composables/useJobs'
import { useFeedState } from './composables/useFeedState'
import { useCommands, useCommandPalette, type Command } from './composables/useCommands'
import { comboFromEvent, formatCombo, useKeybindings } from './composables/useKeybindings'
Expand All @@ -51,7 +52,7 @@ const {
profiles, profilesLoaded, profilesError, activeProfile, activeProfileId, selection, items, visibleItems, unreadCount, search, loadError,
selectedId, selectedItem, actions, pendingAction, actionRuns, sessionLaunchAction, sessionLaunchOptions, sessionLaunchBusy, sessionLaunchError, unreadOnly, feedSort, setFeedSort, title, toasts, dismissToast, clearToasts,
creatingProfile, createProfileError, renamingProfile, renameProfileError, deletingProfile, loadProfiles, createProfile, renameProfile, deleteProfile,
reorderFeeds, selectProfile, selectSidebar, selectUnreadView, selectItem, selectNext, selectPrev,
reorderFeeds, selectProfile, selectSidebar, selectUnreadView, selectItem, openActionRun, selectNext, selectPrev,
toggleUnread, refresh, invokeAction, cancelSessionLaunch, submitSessionLaunch, notWired, openUrl, openSelectedInBrowser, hideWindow,
} = useFeedState()

Expand Down Expand Up @@ -256,11 +257,21 @@ function requestOpenSettings(page: 'application' | 'profile'): void {
// App-global audit log. The titlebar's Activity link replaces the old "polling
// github" indicator; unseenActivity drives its dot.
const { unseenCount: unseenActivity } = useActivity()
const { activeJobs, hasActive: jobsActive } = useJobs()

function openActivity(): void {
void router.push({ name: 'activity' })
}

async function openJobRun(commandID: number): Promise<void> {
const job = activeJobs.value.find((candidate) => candidate.commandId === commandID)
if (!job || !activeProfileId.value) return
await router.push({ name: 'feed', params: { profileId: activeProfileId.value } })
if (route.name !== 'feed') return
await selectSidebar({ type: 'all' })
await openActionRun(job.target, job.actionId, commandID)
}

function closeSettings(): void {
openFeed()
}
Expand Down Expand Up @@ -586,6 +597,8 @@ onUnmounted(() => {
:activity-active="activityActive"
:error-count="errorCount"
:unseen-activity="unseenActivity"
:jobs-active="jobsActive"
:active-jobs="activeJobs"
:can-go-back="canGoBack"
:can-go-forward="canGoForward"
:sidebar-collapsed="sidebarCollapsed"
Expand All @@ -594,6 +607,7 @@ onUnmounted(() => {
@forward="router.forward()"
@open-error-node="openErrorNode"
@open-activity="openActivity"
@open-job-run="openJobRun"
@toggle-sidebar="toggleSidebar"
@open-palette="togglePalette"
@toggle-maximise="toggleMaximise"
Expand Down
59 changes: 59 additions & 0 deletions desktop/frontend/src/components/JobsPopover.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script setup lang="ts">
import IconCheck from '~icons/lucide/check'
import IconCircleAlert from '~icons/lucide/circle-alert'
import IconClock3 from '~icons/lucide/clock-3'
import IconExternalLink from '~icons/lucide/external-link'
import IconLoader from '~icons/lucide/loader'
import type { Job } from '../../bindings/github.com/colonyops/hive/internal/desktop/jobs/models'

defineProps<{ jobs: Job[] }>()
const emit = defineEmits<{ 'open-run': [commandId: number] }>()

function statusClasses(status: string): string {
if (status === 'failed') return 'border-severity-error-border bg-severity-error-tint text-severity-error'
if (status === 'done') return 'border-severity-success-border bg-severity-success-tint text-severity-success'
return 'border-border bg-chip text-text-2'
}
</script>

<template>
<section
class="absolute right-0 top-[calc(100%+6px)] z-50 w-[340px] overflow-hidden rounded-lg border border-border bg-raised shadow-xl"
style="--wails-draggable: no-drag"
role="dialog"
aria-label="Action jobs"
data-testid="jobs-popover"
>
<header class="border-b border-border px-3.5 py-2.5">
<div class="text-xs font-semibold text-text">Action jobs</div>
<div class="mt-0.5 text-[10.5px] text-text-3">Running and recently completed work</div>
</header>
<ul class="max-h-80 divide-y divide-border overflow-y-auto">
<li v-for="job in jobs" :key="job.id" class="flex items-start gap-2.5 px-3.5 py-3" :data-testid="`job-row-${job.id}`">
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border" :class="statusClasses(job.status)">
<IconLoader v-if="job.status === 'running'" class="size-3.5 animate-spin" />
<IconClock3 v-else-if="job.status === 'queued'" class="size-3.5" />
<IconCheck v-else-if="job.status === 'done'" class="size-3.5" />
<IconCircleAlert v-else class="size-3.5" />
</span>
<div class="min-w-0 flex-1">
<div class="truncate text-[12px] font-medium text-text">{{ job.label || job.actionId }}</div>
<div class="mt-0.5 flex items-center gap-1.5 text-[10.5px]">
<span :class="job.status === 'failed' ? 'text-severity-error' : 'text-text-3'">{{ job.step }}</span>
<span v-if="job.target" class="truncate text-text-4">· {{ job.target }}</span>
</div>
<div v-if="job.error" class="mt-1 line-clamp-2 text-[10.5px] text-severity-error">{{ job.error }}</div>
</div>
<button
v-if="job.commandId"
type="button"
class="mt-0.5 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-text-3 hover:bg-chip hover:text-text"
:aria-label="`Open action run for ${job.label || job.actionId}`"
title="Open action run"
:data-testid="`job-open-run-${job.id}`"
@click="emit('open-run', job.commandId)"
><IconExternalLink class="size-3.5" /></button>
</li>
</ul>
</section>
</template>
35 changes: 33 additions & 2 deletions desktop/frontend/src/components/TitleBar.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { ref, watch } from 'vue'
import IconActivity from '~icons/lucide/activity'
import IconArrowLeft from '~icons/lucide/arrow-left'
import IconArrowRight from '~icons/lucide/arrow-right'
import IconPanelLeftClose from '~icons/lucide/panel-left-close'
import IconPanelLeftOpen from '~icons/lucide/panel-left-open'
import IconSearch from '~icons/lucide/search'
import IconTriangleAlert from '~icons/lucide/triangle-alert'
import IconLoader from '~icons/lucide/loader'
import JobsPopover from './JobsPopover.vue'
import type { Job } from '../../bindings/github.com/colonyops/hive/internal/desktop/jobs/models'

// The bar is a three-column grid: a left cluster (sidebar toggle), a center
// cluster (history + command-palette launcher) that stays centered in the
Expand All @@ -20,11 +25,13 @@ import IconTriangleAlert from '~icons/lucide/triangle-alert'
// events recorded since the user last opened that page, shown as a pulsing dot.
// sidebarCollapsed drives the panel-toggle glyph; canToggleSidebar hides the
// toggle in views that have no feed sidebar (settings, flows, onboarding).
defineProps<{
const props = defineProps<{
profileName?: string
activityActive?: boolean
errorCount?: number
unseenActivity?: number
jobsActive?: boolean
activeJobs?: Job[]
canGoBack?: boolean
canGoForward?: boolean
sidebarCollapsed?: boolean
Expand All @@ -35,6 +42,7 @@ const emit = defineEmits<{
forward: []
'open-error-node': []
'open-activity': []
'open-job-run': [commandId: number]
'toggle-sidebar': []
'open-palette': []
'toggle-maximise': []
Expand All @@ -45,6 +53,11 @@ const emit = defineEmits<{
// them and keep the height in sync with InvisibleTitleBarHeight (42) in main.go
// so the controls stay vertically centered.
const isMac = navigator.userAgent.includes('Mac')
const jobsRoot = ref<HTMLElement | null>(null)
const jobsOpen = ref(false)

onClickOutside(jobsRoot, () => { jobsOpen.value = false })
watch(() => props.jobsActive, (active) => { if (!active) jobsOpen.value = false })

// Double-clicking the draggable title bar zooms the window, matching the native
// macOS title-bar gesture (we draw our own chrome, so we implement it). Clicks
Expand Down Expand Up @@ -111,7 +124,7 @@ function onTitlebarDblclick(event: MouseEvent): void {
</div>
<div v-else class="flex-1" />

<!-- Right: error / activity chips -->
<!-- Right: error / live jobs / activity chips -->
<div class="flex min-w-0 flex-1 items-center justify-end gap-2 pl-2 pr-3">
<button
v-if="errorCount && errorCount > 0"
Expand All @@ -120,6 +133,24 @@ function onTitlebarDblclick(event: MouseEvent): void {
data-testid="titlebar-error-chip"
@click="emit('open-error-node')"
><IconTriangleAlert class="size-3" />{{ errorCount }} error<template v-if="errorCount !== 1">s</template></button>
<div v-if="profileName && jobsActive" ref="jobsRoot" class="relative shrink-0" style="--wails-draggable: no-drag">
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-accent/50 bg-accent/10 px-2 py-1 text-[11.5px] font-medium text-accent hover:border-accent"
data-testid="titlebar-jobs"
aria-label="Show action jobs"
:aria-expanded="jobsOpen"
@click="jobsOpen = !jobsOpen"
>
<IconLoader class="size-3.5 animate-spin" />
{{ activeJobs?.length ?? 0 }} job<template v-if="(activeJobs?.length ?? 0) !== 1">s</template>
</button>
<JobsPopover
v-if="jobsOpen"
:jobs="activeJobs ?? []"
@open-run="(commandId) => { jobsOpen = false; emit('open-job-run', commandId) }"
/>
</div>
<!-- A link to the Activity audit log. A pulsing dot flags activity
recorded since the page was last opened. -->
<button
Expand Down
18 changes: 18 additions & 0 deletions desktop/frontend/src/components/__tests__/TitleBar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ describe('TitleBar', () => {
expect(active.find('[data-testid="titlebar-activity-unseen"]').exists()).toBe(false)
})

it('shows live jobs, opens the popover, and emits the selected command', async () => {
const job = {
id: 7, createdAt: 1, updatedAt: 2, status: 'done', label: 'Review PR',
step: 'Completed', actionId: 'review', target: 'pr-1', commandId: 42,
}
const wrapper = mount(TitleBar, { props: { profileName: 'Triage', jobsActive: true, activeJobs: [job] } })
const chip = wrapper.find('[data-testid="titlebar-jobs"]')
expect(chip.exists()).toBe(true)
expect(chip.text()).toContain('1 job')
await chip.trigger('click')
expect(wrapper.find('[data-testid="jobs-popover"]').exists()).toBe(true)
await wrapper.find('[data-testid="job-open-run-7"]').trigger('click')
expect(wrapper.emitted('open-job-run')).toEqual([[42]])

await wrapper.setProps({ jobsActive: false })
expect(wrapper.find('[data-testid="titlebar-jobs"]').exists()).toBe(false)
})

it('exposes enabled back and forward history controls', async () => {
const wrapper = mount(TitleBar, { props: { profileName: 'Triage', canGoBack: true, canGoForward: true } })

Expand Down
Loading