refactor(workflow): move field help into tooltips

This commit is contained in:
Lux
2026-07-14 09:09:30 +08:00
parent cf903d24f0
commit e52b27953b
4 changed files with 180 additions and 54 deletions
@@ -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 {
+40 -12
View File
@@ -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()