mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-18 14:33:04 +00:00
refactor(workflow): move field help into tooltips
This commit is contained in:
@@ -5,6 +5,7 @@ import { NodeResizer } from '@vue-flow/node-resizer'
|
||||
import { NInput, NSelect, NSwitch, NTooltip, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WorkflowModelSelector from './WorkflowModelSelector.vue'
|
||||
import WorkflowFieldHelp from './WorkflowFieldHelp.vue'
|
||||
import type { WorkflowAgentNodeData, WorkflowAgentNodeEditableData } from './types'
|
||||
import type { CodingAgentApiMode } from '@/api/coding-agents'
|
||||
import type { ProviderApiMode } from '@/api/hermes/system'
|
||||
@@ -194,8 +195,14 @@ async function uploadImages(files: File[]) {
|
||||
:placeholder="t('chat.reasoningEffort.tooltip')"
|
||||
@update:value="value => updateField('reasoningEffort', value as string)"
|
||||
/>
|
||||
<label class="node-field-row">
|
||||
<span>{{ t('workflow.node.join') }}</span>
|
||||
<div class="node-field-row">
|
||||
<span class="node-field-label-row">
|
||||
<span>{{ t('workflow.node.join') }}</span>
|
||||
<WorkflowFieldHelp
|
||||
:text="data.orchestration?.join === 'any' ? t('workflow.node.joinAnyHelp') : t('workflow.node.joinAllHelp')"
|
||||
test-id="workflow-node-join-help"
|
||||
/>
|
||||
</span>
|
||||
<NSelect
|
||||
:value="data.orchestration?.join || 'all'"
|
||||
:options="[{ label: t('workflow.node.joinAll'), value: 'all' }, { label: t('workflow.node.joinAny'), value: 'any' }]"
|
||||
@@ -203,8 +210,7 @@ async function uploadImages(files: File[]) {
|
||||
:disabled="data.readonly"
|
||||
@update:value="value => updateField('orchestration', { join: value as 'all' | 'any' })"
|
||||
/>
|
||||
</label>
|
||||
<small class="node-field-help" data-testid="workflow-node-join-help">{{ data.orchestration?.join === 'any' ? t('workflow.node.joinAnyHelp') : t('workflow.node.joinAllHelp') }}</small>
|
||||
</div>
|
||||
<label class="node-toggle-row">
|
||||
<span>{{ t('workflow.node.approvalRequired') }}</span>
|
||||
<NSwitch
|
||||
@@ -473,11 +479,11 @@ async function uploadImages(files: File[]) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-field-help {
|
||||
margin-top: -5px;
|
||||
color: $text-muted;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
.node-field-label-row {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-toggle-row {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { NTooltip } from 'naive-ui'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
secondaryText?: string
|
||||
testId?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="workflow-field-help-button"
|
||||
type="button"
|
||||
:aria-label="text"
|
||||
:data-testid="testId"
|
||||
@click.stop.prevent
|
||||
@pointerdown.stop
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div class="workflow-field-help-tooltip">
|
||||
<span>{{ text }}</span>
|
||||
<span v-if="secondaryText">{{ secondaryText }}</span>
|
||||
</div>
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflow-field-help-button {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--accent-info);
|
||||
background: rgba(var(--accent-info-rgb), 0.1);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-field-help-tooltip {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import { parseWorkflowConditionValue, serializeWorkflowConditionValue, workflowC
|
||||
import { workflowImportConfirmationText } from '@/utils/workflow-import'
|
||||
import { createConnectedAgentTransaction, type CanvasTransaction } from '@/utils/workflow-canvas'
|
||||
import WorkflowAgentNode from '@/components/hermes/workflow/WorkflowAgentNode.vue'
|
||||
import WorkflowFieldHelp from '@/components/hermes/workflow/WorkflowFieldHelp.vue'
|
||||
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
|
||||
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
|
||||
import MessageList from '@/components/hermes/chat/MessageList.vue'
|
||||
@@ -2504,12 +2505,25 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
|
||||
<NModal v-model:show="edgeEditorVisible" preset="card" :title="t('workflow.edgeEditor.title')" style="width: min(680px, 94vw)">
|
||||
<div class="workflow-create-form workflow-edge-editor-form">
|
||||
<div class="workflow-edge-guide" data-testid="workflow-edge-guide">{{ t('workflow.edgeEditor.guideIntro') }}</div>
|
||||
<label class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.route') }}</span>
|
||||
<div class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.route') }}</span>
|
||||
<WorkflowFieldHelp
|
||||
:text="t('workflow.edgeEditor.routeHelp')"
|
||||
:secondary-text="t('workflow.edgeEditor.routeExample')"
|
||||
test-id="workflow-edge-route-help"
|
||||
/>
|
||||
</span>
|
||||
<NSelect :value="edgeEditorRoute" :options="workflowEdgeRouteOptions" @update:value="handleEdgeEditorRouteChange" />
|
||||
<span class="workflow-field-help">{{ t('workflow.edgeEditor.routeHelp') }}</span>
|
||||
<span class="workflow-field-example">{{ t('workflow.edgeEditor.routeExample') }}</span>
|
||||
</label>
|
||||
<label class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.conditionPath') }}</span>
|
||||
</div>
|
||||
<div class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.conditionPath') }}</span>
|
||||
<WorkflowFieldHelp
|
||||
:text="t(`workflow.edgeEditor.conditionPathHelp.${edgeEditorRoute}`)"
|
||||
test-id="workflow-edge-condition-path-help"
|
||||
/>
|
||||
</span>
|
||||
<NSelect
|
||||
:value="edgeEditorConditionPathPreset"
|
||||
:options="workflowConditionPathOptions"
|
||||
@@ -2522,25 +2536,39 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
|
||||
data-testid="workflow-edge-condition-path"
|
||||
:placeholder="t('workflow.edgeEditor.conditionPathPlaceholder')"
|
||||
/>
|
||||
<span class="workflow-field-help">{{ t(`workflow.edgeEditor.conditionPathHelp.${edgeEditorRoute}`) }}</span>
|
||||
</label>
|
||||
<label v-if="edgeEditorConditionPathPreset !== 'route-only'" class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.operator') }}</span>
|
||||
<NSelect v-model:value="edgeEditorConditionOperator" data-testid="workflow-edge-condition-operator" :options="workflowEdgeOperatorOptions" />
|
||||
<span class="workflow-field-help" data-testid="workflow-edge-operator-help">{{ workflowEdgeOperatorHelp }}</span>
|
||||
</label>
|
||||
<label v-if="edgeEditorConditionPathPreset !== 'route-only' && workflowConditionNeedsValue(edgeEditorConditionOperator)" class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.value') }}</span>
|
||||
<NInput v-model:value="edgeEditorConditionValue" data-testid="workflow-edge-condition-value" :placeholder="t('workflow.edgeEditor.conditionValuePlaceholder')" />
|
||||
<span class="workflow-field-help">{{ t('workflow.edgeEditor.valueHelp') }}</span>
|
||||
</label>
|
||||
<div class="workflow-feedback-field">
|
||||
<NCheckbox v-model:checked="edgeEditorFeedback">{{ t('workflow.edgeEditor.feedback') }}</NCheckbox>
|
||||
<span class="workflow-field-help">{{ t('workflow.edgeEditor.feedbackHelp') }}</span>
|
||||
</div>
|
||||
<label v-if="edgeEditorFeedback" class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.maxIterations') }}</span>
|
||||
<div v-if="edgeEditorConditionPathPreset !== 'route-only'" class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.operator') }}</span>
|
||||
<WorkflowFieldHelp :text="workflowEdgeOperatorHelp" test-id="workflow-edge-operator-help" />
|
||||
</span>
|
||||
<NSelect v-model:value="edgeEditorConditionOperator" data-testid="workflow-edge-condition-operator" :options="workflowEdgeOperatorOptions" />
|
||||
</div>
|
||||
<div v-if="edgeEditorConditionPathPreset !== 'route-only' && workflowConditionNeedsValue(edgeEditorConditionOperator)" class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.value') }}</span>
|
||||
<WorkflowFieldHelp :text="t('workflow.edgeEditor.valueHelp')" test-id="workflow-edge-condition-value-help" />
|
||||
</span>
|
||||
<NInput v-model:value="edgeEditorConditionValue" data-testid="workflow-edge-condition-value" :placeholder="t('workflow.edgeEditor.conditionValuePlaceholder')" />
|
||||
</div>
|
||||
<div class="workflow-feedback-field">
|
||||
<div class="workflow-feedback-heading">
|
||||
<NCheckbox v-model:checked="edgeEditorFeedback">{{ t('workflow.edgeEditor.feedback') }}</NCheckbox>
|
||||
<WorkflowFieldHelp :text="t('workflow.edgeEditor.feedbackHelp')" test-id="workflow-edge-feedback-help" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="edgeEditorFeedback" class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.maxIterations') }}</span>
|
||||
<WorkflowFieldHelp :text="t('workflow.edgeEditor.maxIterationsHelp')" test-id="workflow-edge-max-iterations-help" />
|
||||
</span>
|
||||
<NInput v-model:value="edgeEditorMaxIterations" inputmode="numeric" />
|
||||
<span class="workflow-field-help">{{ t('workflow.edgeEditor.maxIterationsHelp') }}</span>
|
||||
</label>
|
||||
<label v-if="edgeEditorFeedback" class="workflow-field"><span class="workflow-field-label">{{ t('workflow.edgeEditor.loopId') }}</span>
|
||||
</div>
|
||||
<div v-if="edgeEditorFeedback" class="workflow-field">
|
||||
<span class="workflow-field-label-row">
|
||||
<span class="workflow-field-label">{{ t('workflow.edgeEditor.loopId') }}</span>
|
||||
<WorkflowFieldHelp :text="t('workflow.edgeEditor.loopIdHelp')" test-id="workflow-edge-loop-id-help" />
|
||||
</span>
|
||||
<NSelect
|
||||
v-model:value="edgeEditorLoopIdSelection"
|
||||
:options="workflowLoopIdOptions"
|
||||
@@ -2549,8 +2577,7 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
|
||||
data-testid="workflow-edge-loop-id"
|
||||
:placeholder="t('workflow.edgeEditor.loopIdPlaceholder')"
|
||||
/>
|
||||
<span class="workflow-field-help">{{ t('workflow.edgeEditor.loopIdHelp') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<NSpace justify="end"><NButton @click="edgeEditorVisible = false">{{ t('common.cancel') }}</NButton><NButton type="primary" @click="saveEdgeEditor">{{ t('common.save') }}</NButton></NSpace>
|
||||
</div>
|
||||
</NModal>
|
||||
@@ -2877,15 +2904,12 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workflow-field-help,
|
||||
.workflow-field-example {
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workflow-field-example {
|
||||
color: var(--accent-info);
|
||||
.workflow-field-label-row,
|
||||
.workflow-feedback-heading {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workflow-feedback-field {
|
||||
|
||||
@@ -67,14 +67,23 @@ test('workflow canvas exposes orchestration editing and portability controls', a
|
||||
await expect(evidence.getByText('loop:a', { exact: true })).toBeVisible()
|
||||
await expect(evidence.getByText('rerun:2 · loop:a#2', { exact: true })).toHaveCount(3)
|
||||
await page.locator('.workflow-run-item').click()
|
||||
await expect(page.getByTestId('workflow-node-join-help')).toHaveCount(2)
|
||||
await expect(page.getByText('All incoming routes must be taken; if one does not match, this node is skipped. Example: wait for both parallel checks.', { exact: true })).toHaveCount(2)
|
||||
const joinHelpIcons = page.getByTestId('workflow-node-join-help')
|
||||
const joinHelp = page.getByText('All incoming routes must be taken; if one does not match, this node is skipped. Example: wait for both parallel checks.', { exact: true })
|
||||
await expect(joinHelpIcons).toHaveCount(2)
|
||||
await expect(joinHelp).toHaveCount(0)
|
||||
await joinHelpIcons.first().hover()
|
||||
await expect(joinHelp).toBeVisible()
|
||||
const edge = page.locator('.vue-flow__edge').first()
|
||||
await edge.click({ force: true })
|
||||
const edgeDialog = page.getByRole('dialog')
|
||||
await expect(page.getByText('Edit connection', { exact: true })).toBeVisible()
|
||||
await expect(edgeDialog.getByText('First match the source result. success: source succeeded; failure: source failed; always: either result. A condition, when present, must also match.', { exact: true })).toBeVisible()
|
||||
await expect(edgeDialog.getByText('Example: use success for the normal path, failure for error handling, and always for cleanup.', { exact: true })).toBeVisible()
|
||||
const routeHelp = page.getByText('First match the source result. success: source succeeded; failure: source failed; always: either result. A condition, when present, must also match.', { exact: true })
|
||||
const routeExample = page.getByText('Example: use success for the normal path, failure for error handling, and always for cleanup.', { exact: true })
|
||||
await expect(routeHelp).toHaveCount(0)
|
||||
await expect(routeExample).toHaveCount(0)
|
||||
await page.getByTestId('workflow-edge-route-help').hover()
|
||||
await expect(routeHelp).toBeVisible()
|
||||
await expect(routeExample).toBeVisible()
|
||||
await edgeDialog.locator('.n-select').first().click()
|
||||
for (const route of ['Success only (success)', 'Failure only (failure)', 'Always (always)']) {
|
||||
await expect(page.getByText(route, { exact: true }).last()).toBeVisible()
|
||||
@@ -88,10 +97,19 @@ test('workflow canvas exposes orchestration editing and portability controls', a
|
||||
await expect(page.getByText('Failure error text (error)', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('Custom path (advanced)', { exact: true }).last()).toBeVisible()
|
||||
await page.getByText('Successful reply text (output, recommended)', { exact: true }).last().click()
|
||||
await expect(edgeDialog.getByText('For success, output is recommended. Choose Route only when no content check is needed.', { exact: true })).toBeVisible()
|
||||
await expect(edgeDialog.getByText('Exactly equal, including type. Example: output equals "APPROVED".', { exact: true })).toBeVisible()
|
||||
await expect(edgeDialog.getByText('Enter valid JSON. Text needs double quotes; numbers and booleans do not. Types are not converted: "42" is different from 42.', { exact: true })).toBeVisible()
|
||||
await expect(edgeDialog.getByText('Marks this backward connection as a bounded loop. It repeats only while the route and condition match, and stops at Maximum iterations.', { exact: true })).toBeVisible()
|
||||
const conditionHelp = page.getByText('For success, output is recommended. Choose Route only when no content check is needed.', { exact: true })
|
||||
const operatorHelp = page.getByText('Exactly equal, including type. Example: output equals "APPROVED".', { exact: true })
|
||||
const valueHelp = page.getByText('Enter valid JSON. Text needs double quotes; numbers and booleans do not. Types are not converted: "42" is different from 42.', { exact: true })
|
||||
const feedbackHelp = page.getByText('Marks this backward connection as a bounded loop. It repeats only while the route and condition match, and stops at Maximum iterations.', { exact: true })
|
||||
for (const help of [conditionHelp, operatorHelp, valueHelp, feedbackHelp]) await expect(help).toHaveCount(0)
|
||||
await page.getByTestId('workflow-edge-condition-path-help').hover()
|
||||
await expect(conditionHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-operator-help').hover()
|
||||
await expect(operatorHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-condition-value-help').hover()
|
||||
await expect(valueHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-feedback-help').hover()
|
||||
await expect(feedbackHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-condition-operator').click()
|
||||
for (const operator of [
|
||||
'Equals (equals)', 'Does not equal (not_equals)', 'Contains (contains)', 'Does not contain (not_contains)',
|
||||
@@ -101,10 +119,20 @@ test('workflow canvas exposes orchestration editing and portability controls', a
|
||||
await expect(page.getByText(operator, { exact: true }).last()).toBeVisible()
|
||||
}
|
||||
await page.getByText('Greater than (greater_than)', { exact: true }).last().click()
|
||||
await expect(page.getByTestId('workflow-edge-operator-help')).toHaveText('Both actual value and Value must be JSON numbers; matches when actual is greater.')
|
||||
await page.getByTestId('workflow-edge-condition-value').fill('42')
|
||||
const numberOperatorHelp = page.getByText('Both actual value and Value must be JSON numbers; matches when actual is greater.', { exact: true })
|
||||
await expect(numberOperatorHelp).toHaveCount(0)
|
||||
await page.getByTestId('workflow-edge-operator-help').hover()
|
||||
await expect(numberOperatorHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-condition-value').locator('input').fill('42')
|
||||
await page.getByText('Feedback loop').click()
|
||||
await expect(edgeDialog.getByText('Total loop passes allowed, from 1 to 100. The run also has a global execution safety budget.', { exact: true })).toBeVisible()
|
||||
const maxIterationsHelp = page.getByText('Total loop passes allowed, from 1 to 100. The run also has a global execution safety budget.', { exact: true })
|
||||
const loopIdHelp = page.getByText('This only labels the loop in execution history; it does not change when the loop runs. Keep Automatic unless an advanced multi-loop design needs a stable custom ID.', { exact: true })
|
||||
await expect(maxIterationsHelp).toHaveCount(0)
|
||||
await expect(loopIdHelp).toHaveCount(0)
|
||||
await page.getByTestId('workflow-edge-max-iterations-help').hover()
|
||||
await expect(maxIterationsHelp).toBeVisible()
|
||||
await page.getByTestId('workflow-edge-loop-id-help').hover()
|
||||
await expect(loopIdHelp).toBeVisible()
|
||||
const loopIdSelect = page.getByTestId('workflow-edge-loop-id')
|
||||
await expect(loopIdSelect).toBeVisible()
|
||||
await loopIdSelect.click()
|
||||
@@ -118,7 +146,7 @@ test('workflow canvas exposes orchestration editing and portability controls', a
|
||||
await edge.click({ force: true })
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
await expect(page.getByTestId('workflow-edge-condition-path-preset')).toContainText('Successful reply text (output, recommended)')
|
||||
await expect(page.getByTestId('workflow-edge-condition-value')).toHaveValue('42')
|
||||
await expect(page.getByTestId('workflow-edge-condition-value').locator('input')).toHaveValue('42')
|
||||
await expect(page.getByTestId('workflow-edge-loop-id')).toContainText('review-loop')
|
||||
await page.getByTestId('workflow-edge-condition-operator').click({ force: true })
|
||||
await page.getByText('Exists (exists)', { exact: true }).last().click()
|
||||
|
||||
Reference in New Issue
Block a user