Compare commits

..

56 Commits

Author SHA1 Message Date
Waleed
b45f3962fc v0.5.89: resume execution on refresh, google books, tool input subblock improvements 2026-02-13 00:36:54 -08:00
Waleed
07d50f8fe1 v0.5.88: interactions api for gemini, trigger machine size increase, confluence ops 2026-02-11 15:36:55 -08:00
Vikhyath Mondreti
27973953f6 v0.5.87: workflow block auth fix 2026-02-10 22:33:55 -08:00
Waleed
50585273ce v0.5.86: server side copilot, copilot mcp, error notifications, jira outputs destructuring, slack trigger improvements 2026-02-10 21:49:58 -08:00
Vikhyath Mondreti
654cb2b407 v0.5.85: deployment improvements 2026-02-09 10:49:33 -08:00
Waleed
6c66521d64 v0.5.84: model request sanitization 2026-02-07 19:06:53 -08:00
Vikhyath Mondreti
479cd347ad v0.5.83: agent skills, concurrent workers for v8s, airweave integration 2026-02-07 12:27:11 -08:00
Waleed
a3a99eda19 v0.5.82: slack trigger files, pagination for linear, executor fixes 2026-02-06 00:41:52 -08:00
Waleed
1a66d48add v0.5.81: traces fix, additional confluence tools, azure anthropic support, opus 4.6 2026-02-05 11:28:54 -08:00
Waleed
46822e91f3 v0.5.80: lock feature, enterprise modules, time formatting consolidation, files, UX and UI improvements, longer timeouts 2026-02-04 18:27:05 -08:00
Waleed
2bb68335ee v0.5.79: longer MCP tools timeout, optimize loop/parallel regeneration, enrich.so integration 2026-01-31 21:57:56 -08:00
Waleed
8528fbe2d2 v0.5.78: billing fixes, mcp timeout increase, reactquery migrations, updated tool param visibilities, DSPy and Google Maps integrations 2026-01-31 13:48:22 -08:00
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
22 changed files with 370 additions and 636 deletions

View File

@@ -1,2 +1,2 @@
export type { StatusBarSegment } from './status-bar' export type { StatusBarSegment } from './status-bar'
export { StatusBar } from './status-bar' export { default, StatusBar } from './status-bar'

View File

@@ -8,7 +8,7 @@ export interface StatusBarSegment {
timestamp: string timestamp: string
} }
function StatusBarInner({ export function StatusBar({
segments, segments,
selectedSegmentIndices, selectedSegmentIndices,
onSegmentClick, onSegmentClick,
@@ -127,45 +127,4 @@ function StatusBarInner({
) )
} }
/** export default memo(StatusBar)
* Custom equality function for StatusBar memo.
* Performs structural comparison of segments array to avoid re-renders
* when poll data returns new object references with identical content.
*/
function areStatusBarPropsEqual(
prev: Parameters<typeof StatusBarInner>[0],
next: Parameters<typeof StatusBarInner>[0]
): boolean {
if (prev.workflowId !== next.workflowId) return false
if (prev.segmentDurationMs !== next.segmentDurationMs) return false
if (prev.preferBelow !== next.preferBelow) return false
if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) {
if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false
if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false
for (let i = 0; i < prev.selectedSegmentIndices.length; i++) {
if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false
}
}
if (prev.segments !== next.segments) {
if (prev.segments.length !== next.segments.length) return false
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = next.segments[i]
if (
ps.successRate !== ns.successRate ||
ps.hasExecutions !== ns.hasExecutions ||
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp
) {
return false
}
}
}
return true
}
export const StatusBar = memo(StatusBarInner, areStatusBarPropsEqual)

View File

@@ -1,2 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list' export type { WorkflowExecutionItem } from './workflows-list'
export { WorkflowsList } from './workflows-list' export { default, WorkflowsList } from './workflows-list'

View File

@@ -14,7 +14,7 @@ export interface WorkflowExecutionItem {
overallSuccessRate: number overallSuccessRate: number
} }
function WorkflowsListInner({ export function WorkflowsList({
filteredExecutions, filteredExecutions,
expandedWorkflowId, expandedWorkflowId,
onToggleWorkflow, onToggleWorkflow,
@@ -103,7 +103,7 @@ function WorkflowsListInner({
<StatusBar <StatusBar
segments={workflow.segments} segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null} selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick} onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId} workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs} segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2} preferBelow={idx < 2}
@@ -124,4 +124,4 @@ function WorkflowsListInner({
) )
} }
export const WorkflowsList = memo(WorkflowsListInner) export default memo(WorkflowsList)

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -141,10 +141,10 @@ function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution {
} }
} }
function DashboardInner({ stats, isLoading, error }: DashboardProps) { export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({}) const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({}) const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const lastAnchorIndicesRef = useRef<Record<string, number>>({}) const barsAreaRef = useRef<HTMLDivElement | null>(null)
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore() const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
@@ -152,79 +152,20 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => { const { executions, aggregateSegments, segmentMs } = useMemo(() => {
if (!stats) { if (!stats) {
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 } return { executions: [], aggregateSegments: [], segmentMs: 0 }
} }
const workflowExecutions = stats.workflows.map(toWorkflowExecution)
return { return {
rawExecutions: stats.workflows.map(toWorkflowExecution), executions: workflowExecutions,
aggregateSegments: stats.aggregateSegments, aggregateSegments: stats.aggregateSegments,
segmentMs: stats.segmentMs, segmentMs: stats.segmentMs,
} }
}, [stats]) }, [stats])
/**
* Stabilize execution objects: reuse previous references for workflows
* whose segment data hasn't structurally changed between polls.
* This prevents cascading re-renders through WorkflowsList → StatusBar.
*/
const prevExecutionsRef = useRef<WorkflowExecution[]>([])
const executions = useMemo(() => {
const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e]))
let anyChanged = false
const result = rawExecutions.map((exec) => {
const prev = prevMap.get(exec.workflowId)
if (!prev) {
anyChanged = true
return exec
}
if (
prev.overallSuccessRate !== exec.overallSuccessRate ||
prev.workflowName !== exec.workflowName ||
prev.segments.length !== exec.segments.length
) {
anyChanged = true
return exec
}
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = exec.segments[i]
if (
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp ||
ps.avgDurationMs !== ns.avgDurationMs ||
ps.p50Ms !== ns.p50Ms ||
ps.p90Ms !== ns.p90Ms ||
ps.p99Ms !== ns.p99Ms
) {
anyChanged = true
return exec
}
}
return prev
})
if (
!anyChanged &&
result.length === prevExecutionsRef.current.length &&
result.every((r, i) => r === prevExecutionsRef.current[i])
) {
return prevExecutionsRef.current
}
return result
}, [rawExecutions])
useEffect(() => {
prevExecutionsRef.current = executions
}, [executions])
const lastExecutionByWorkflow = useMemo(() => { const lastExecutionByWorkflow = useMemo(() => {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const wf of executions) { for (const wf of executions) {
@@ -371,8 +312,6 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
[toggleWorkflowId] [toggleWorkflowId]
) )
lastAnchorIndicesRef.current = lastAnchorIndices
/** /**
* Handles segment click for selecting time segments. * Handles segment click for selecting time segments.
* @param workflowId - The workflow containing the segment * @param workflowId - The workflow containing the segment
@@ -422,7 +361,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
} else if (mode === 'range') { } else if (mode === 'range') {
setSelectedSegments((prev) => { setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || [] const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] = const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor] anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i) const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
@@ -431,12 +370,12 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}) })
} }
}, },
[] [lastAnchorIndices]
) )
useEffect(() => { useEffect(() => {
setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setSelectedSegments({})
setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setLastAnchorIndices({})
}, [stats, timeRange, workflowIds, searchQuery]) }, [stats, timeRange, workflowIds, searchQuery])
if (isLoading) { if (isLoading) {
@@ -554,7 +493,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
</div> </div>
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList <WorkflowsList
filteredExecutions={filteredExecutions as WorkflowExecution[]} filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId} expandedWorkflowId={expandedWorkflowId}
@@ -568,5 +507,3 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
) )
} }
export default memo(DashboardInner)

View File

@@ -43,199 +43,184 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
/** /**
* Workflow Output section with code viewer, copy, search, and context menu functionality * Workflow Output section with code viewer, copy, search, and context menu functionality
*/ */
const WorkflowOutputSection = memo( function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) { const contentRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null) const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState(false)
const copyTimerRef = useRef<number | null>(null)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) // Context menu state
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const { const {
isSearchActive, isSearchActive,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
matchCount, matchCount,
currentMatchIndex, currentMatchIndex,
activateSearch, activateSearch,
closeSearch, closeSearch,
goToNextMatch, goToNextMatch,
goToPreviousMatch, goToPreviousMatch,
handleMatchCountChange, handleMatchCountChange,
searchInputRef, searchInputRef,
} = useCodeViewerFeatures({ contentRef }) } = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true) setIsContextMenuOpen(true)
}, []) }, [])
const closeContextMenu = useCallback(() => { const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false) setIsContextMenuOpen(false)
}, []) }, [])
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString) navigator.clipboard.writeText(jsonString)
setCopied(true) setCopied(true)
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) setTimeout(() => setCopied(false), 1500)
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) closeContextMenu()
closeContextMenu() }, [jsonString, closeContextMenu])
}, [jsonString, closeContextMenu])
useEffect(() => { const handleSearch = useCallback(() => {
return () => { activateSearch()
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) closeContextMenu()
} }, [activateSearch, closeContextMenu])
}, [])
const handleSearch = useCallback(() => { return (
activateSearch() <div className='relative flex min-w-0 flex-col overflow-hidden'>
closeContextMenu() <div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
}, [activateSearch, closeContextMenu]) <Code.Viewer
code={jsonString}
return ( language='json'
<div className='relative flex min-w-0 flex-col overflow-hidden'> className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'> wrapText
<Code.Viewer searchQuery={isSearchActive ? searchQuery : undefined}
code={jsonString} currentMatchIndex={currentMatchIndex}
language='json' onMatchCountChange={handleMatchCountChange}
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]' />
wrapText {/* Glass action buttons overlay */}
searchQuery={isSearchActive ? searchQuery : undefined} {!isSearchActive && (
currentMatchIndex={currentMatchIndex} <div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
onMatchCountChange={handleMatchCountChange} <Tooltip.Root>
/> <Tooltip.Trigger asChild>
{/* Glass action buttons overlay */} <Button
{!isSearchActive && ( type='button'
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button handleCopy()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() {copied ? (
handleCopy() <Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
}} ) : (
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Clipboard className='h-[10px] w-[10px]' />
> )}
{copied ? ( </Button>
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' /> </Tooltip.Trigger>
) : ( <Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
<Clipboard className='h-[10px] w-[10px]' /> </Tooltip.Root>
)} <Tooltip.Root>
</Button> <Tooltip.Trigger asChild>
</Tooltip.Trigger> <Button
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content> type='button'
</Tooltip.Root> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button activateSearch()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() <Search className='h-[10px] w-[10px]' />
activateSearch() </Button>
}} </Tooltip.Trigger>
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Tooltip.Content side='top'>Search</Tooltip.Content>
> </Tooltip.Root>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={closeSearch}
aria-label='Close search'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div> </div>
)} )}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div> </div>
)
}, {/* Search Overlay */}
(prev, next) => prev.output === next.output {isSearchActive && (
) <div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps { interface LogDetailsProps {
/** The log to display details for */ /** The log to display details for */
@@ -293,6 +278,7 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog]) }, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => { const workflowOutput = useMemo(() => {
const executionData = log?.executionData as const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> } | { finalOutput?: Record<string, unknown> }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { memo } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -30,7 +29,7 @@ interface LogRowContextMenuProps {
* Context menu for log rows. * Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering. * Provides quick actions for copying data, navigation, and filtering.
*/ */
export const LogRowContextMenu = memo(function LogRowContextMenu({ export function LogRowContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -122,4 +121,4 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
}) }

View File

@@ -24,7 +24,6 @@ interface LogRowProps {
log: WorkflowLog log: WorkflowLog
isSelected: boolean isSelected: boolean
onClick: (log: WorkflowLog) => void onClick: (log: WorkflowLog) => void
onHover?: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
} }
@@ -34,14 +33,7 @@ interface LogRowProps {
* Uses shallow comparison for the log object. * Uses shallow comparison for the log object.
*/ */
const LogRow = memo( const LogRow = memo(
function LogRow({ function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
log,
isSelected,
onClick,
onHover,
onContextMenu,
selectedRowRef,
}: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow const workflowName = isDeletedWorkflow
@@ -51,8 +43,6 @@ const LogRow = memo(
const handleClick = useCallback(() => onClick(log), [onClick, log]) const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleMouseEnter = useCallback(() => onHover?.(log), [onHover, log])
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (onContextMenu) { if (onContextMenu) {
@@ -71,7 +61,6 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]' isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)} )}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className='flex flex-1 items-center'> <div className='flex flex-1 items-center'>
@@ -153,8 +142,7 @@ const LogRow = memo(
prevProps.log.id === nextProps.log.id && prevProps.log.id === nextProps.log.id &&
prevProps.log.duration === nextProps.log.duration && prevProps.log.duration === nextProps.log.duration &&
prevProps.log.status === nextProps.log.status && prevProps.log.status === nextProps.log.status &&
prevProps.isSelected === nextProps.isSelected && prevProps.isSelected === nextProps.isSelected
prevProps.onHover === nextProps.onHover
) )
} }
) )
@@ -163,7 +151,6 @@ interface RowProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean isFetchingNextPage: boolean
@@ -180,7 +167,6 @@ function Row({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -212,7 +198,6 @@ function Row({
log={log} log={log}
isSelected={isSelected} isSelected={isSelected}
onClick={onLogClick} onClick={onLogClick}
onHover={onLogHover}
onContextMenu={onLogContextMenu} onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null} selectedRowRef={isSelected ? selectedRowRef : null}
/> />
@@ -224,7 +209,6 @@ export interface LogsListProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean hasNextPage: boolean
@@ -243,7 +227,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
hasNextPage, hasNextPage,
@@ -289,7 +272,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -299,7 +281,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { import {
@@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: {
} }
} }
export const NotificationSettings = memo(function NotificationSettings({ export function NotificationSettings({
workspaceId, workspaceId,
open, open,
onOpenChange, onOpenChange,
@@ -144,7 +144,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none' as AlertRule, alertRule: 'none' as AlertRule,
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -212,7 +212,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none', alertRule: 'none',
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -484,6 +484,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: subscription.slackConfig?.channelId || '', slackChannelId: subscription.slackConfig?.channelId || '',
slackChannelName: subscription.slackConfig?.channelName || '', slackChannelName: subscription.slackConfig?.channelName || '',
slackAccountId: subscription.slackConfig?.accountId || '', slackAccountId: subscription.slackConfig?.accountId || '',
useAlertRule: !!subscription.alertConfig,
alertRule: subscription.alertConfig?.rule || 'none', alertRule: subscription.alertConfig?.rule || 'none',
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, failureRatePercent: subscription.alertConfig?.failureRatePercent || 50,
@@ -1288,4 +1289,4 @@ export const NotificationSettings = memo(function NotificationSettings({
</Modal> </Modal>
</> </>
) )
}) }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
@@ -149,7 +149,7 @@ function getTriggerIcon(
* @param props - The component props * @param props - The component props
* @returns The complete logs toolbar * @returns The complete logs toolbar
*/ */
export const LogsToolbar = memo(function LogsToolbar({ export function LogsToolbar({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
isRefreshing, isRefreshing,
@@ -749,4 +749,4 @@ export const LogsToolbar = memo(function LogsToolbar({
</div> </div>
</div> </div>
) )
}) }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
@@ -11,17 +10,12 @@ import {
hasActiveFilters, hasActiveFilters,
} from '@/lib/logs/filters' } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolders } from '@/hooks/queries/folders' import { useFolders } from '@/hooks/queries/folders'
import { import { useDashboardStats, useLogDetail, useLogsList } from '@/hooks/queries/logs'
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce' import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types' import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { import {
Dashboard, Dashboard,
ExecutionSnapshot, ExecutionSnapshot,
@@ -36,38 +30,6 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const
interface LogSelectionState {
selectedLogId: string | null
isSidebarOpen: boolean
}
type LogSelectionAction =
| { type: 'TOGGLE_LOG'; logId: string }
| { type: 'SELECT_LOG'; logId: string }
| { type: 'CLOSE_SIDEBAR' }
| { type: 'TOGGLE_SIDEBAR' }
function logSelectionReducer(
state: LogSelectionState,
action: LogSelectionAction
): LogSelectionState {
switch (action.type) {
case 'TOGGLE_LOG':
if (state.selectedLogId === action.logId && state.isSidebarOpen) {
return { selectedLogId: null, isSidebarOpen: false }
}
return { selectedLogId: action.logId, isSidebarOpen: true }
case 'SELECT_LOG':
return { ...state, selectedLogId: action.logId }
case 'CLOSE_SIDEBAR':
return { selectedLogId: null, isSidebarOpen: false }
case 'TOGGLE_SIDEBAR':
return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state
default:
return state
}
}
/** /**
* Logs page component displaying workflow execution history. * Logs page component displaying workflow execution history.
* Supports filtering, search, live updates, and detailed log inspection. * Supports filtering, search, live updates, and detailed log inspection.
@@ -98,13 +60,11 @@ export default function Logs() {
setWorkspaceId(workspaceId) setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId]) }, [workspaceId, setWorkspaceId])
const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
selectedLogId: null, const [isSidebarOpen, setIsSidebarOpen] = useState(false)
isSidebarOpen: false,
})
const selectedRowRef = useRef<HTMLTableRowElement | null>(null) const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null) const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false) const isInitialized = useRef<boolean>(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -122,13 +82,6 @@ export default function Logs() {
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const isSearchOpenRef = useRef<boolean>(false) const isSearchOpenRef = useRef<boolean>(false)
const refreshTimersRef = useRef(new Set<number>())
const logsRef = useRef<WorkflowLog[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
@@ -141,19 +94,8 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null) const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const queryClient = useQueryClient()
const detailRefetchInterval = useCallback(
(query: { state: { data?: WorkflowLog } }) => {
if (!isLive) return false
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 3000 : false
},
[isLive]
)
const activeLogQuery = useLogDetail(activeLogId ?? undefined, { const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: detailRefetchInterval, refetchInterval: isLive ? 3000 : false,
}) })
const logFilters = useMemo( const logFilters = useMemo(
@@ -212,73 +154,42 @@ export default function Logs() {
return { ...selectedLogFromList, ...activeLogQuery.data } return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen]) }, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
prefetchLogDetail(queryClient, log.id)
},
[queryClient]
)
useFolders(workspaceId) useFolders(workspaceId)
useEffect(() => {
logsRef.current = logs
}, [logs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
useEffect(() => {
selectedLogIdRef.current = selectedLogId
}, [selectedLogId])
useEffect(() => {
logsRefetchRef.current = logsQuery.refetch
}, [logsQuery.refetch])
useEffect(() => {
activeLogRefetchRef.current = activeLogQuery.refetch
}, [activeLogQuery.refetch])
useEffect(() => {
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
timers.forEach((id) => window.clearTimeout(id))
timers.clear()
}
}, [])
useEffect(() => { useEffect(() => {
if (isInitialized.current) { if (isInitialized.current) {
setStoreSearchQuery(debouncedSearchQuery) setStoreSearchQuery(debouncedSearchQuery)
} }
}, [debouncedSearchQuery, setStoreSearchQuery]) }, [debouncedSearchQuery, setStoreSearchQuery])
const handleLogClick = useCallback((log: WorkflowLog) => { const handleLogClick = useCallback(
dispatch({ type: 'TOGGLE_LOG', logId: log.id }) (log: WorkflowLog) => {
}, []) if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLogId(null)
return
}
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLogId, isSidebarOpen]
)
const handleNavigateNext = useCallback(() => { const handleNavigateNext = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex < logs.length - 1) {
const currentLogs = logsRef.current setSelectedLogId(logs[selectedLogIndex + 1].id)
if (idx < currentLogs.length - 1) {
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleNavigatePrev = useCallback(() => { const handleNavigatePrev = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex > 0) {
if (idx > 0) { setSelectedLogId(logs[selectedLogIndex - 1].id)
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleCloseSidebar = useCallback(() => { const handleCloseSidebar = useCallback(() => {
dispatch({ type: 'CLOSE_SIDEBAR' }) setIsSidebarOpen(false)
setSelectedLogId(null)
}, []) }, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => { const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -349,34 +260,26 @@ export default function Logs() {
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false) logsQuery.refetch()
refreshTimersRef.current.delete(timerId) if (selectedLogId) {
}, REFRESH_SPINNER_DURATION_MS) activeLogQuery.refetch()
refreshTimersRef.current.add(timerId)
logsRefetchRef.current()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
} }
}, []) }, [logsQuery, activeLogQuery, selectedLogId])
const handleToggleLive = useCallback(() => { const handleToggleLive = useCallback(() => {
setIsLive((prev) => { const newIsLive = !isLive
if (!prev) { setIsLive(newIsLive)
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { if (newIsLive) {
setIsVisuallyRefreshing(false) setIsVisuallyRefreshing(true)
refreshTimersRef.current.delete(timerId) setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
}, REFRESH_SPINNER_DURATION_MS) logsQuery.refetch()
refreshTimersRef.current.add(timerId) if (selectedLogId) {
logsRefetchRef.current() activeLogQuery.refetch()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
}
} }
return !prev }
}) }, [isLive, logsQuery, activeLogQuery, selectedLogId])
}, [])
const prevIsFetchingRef = useRef(logsQuery.isFetching) const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => { useEffect(() => {
@@ -386,15 +289,11 @@ export default function Logs() {
if (isLive && !wasFetching && isFetching) { if (isLive && !wasFetching && isFetching) {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
} }
}, [logsQuery.isFetching, isLive]) }, [logsQuery.isFetching, isLive])
const handleExport = useCallback(async () => { const handleExport = async () => {
setIsExporting(true) setIsExporting(true)
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -428,17 +327,7 @@ export default function Logs() {
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
}, [ }
workspaceId,
level,
triggers,
workflowIds,
folderIds,
timeRange,
startDate,
endDate,
debouncedSearchQuery,
])
useEffect(() => { useEffect(() => {
if (!isInitialized.current) { if (!isInitialized.current) {
@@ -459,59 +348,41 @@ export default function Logs() {
}, [initializeFromURL]) }, [initializeFromURL])
const loadMoreLogs = useCallback(() => { const loadMoreLogs = useCallback(() => {
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!logsQuery.isFetching && logsQuery.hasNextPage) {
if (!isFetching && hasNextPage) { logsQuery.fetchNextPage()
fetchNextPage()
} }
}, []) }, [logsQuery])
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return if (isSearchOpenRef.current) return
const currentLogs = logsRef.current if (logs.length === 0) return
const currentIndex = selectedLogIndexRef.current
if (currentLogs.length === 0) return
if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) setSelectedLogId(logs[0].id)
return return
} }
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) { if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
e.preventDefault() e.preventDefault()
handleNavigatePrev() handleNavigatePrev()
} }
if ( if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) {
e.key === 'ArrowDown' &&
!e.metaKey &&
!e.ctrlKey &&
currentIndex < currentLogs.length - 1
) {
e.preventDefault() e.preventDefault()
handleNavigateNext() handleNavigateNext()
} }
if (e.key === 'Enter' && selectedLogIdRef.current) { if (e.key === 'Enter' && selectedLogId) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'TOGGLE_SIDEBAR' }) setIsSidebarOpen(!isSidebarOpen)
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleNavigateNext, handleNavigatePrev]) }, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), [])
const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), [])
const handleSearchOpenChange = useCallback((open: boolean) => {
isSearchOpenRef.current = open
}, [])
const handleClosePreview = useCallback(() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}, [])
const isDashboardView = viewMode === 'dashboard' const isDashboardView = viewMode === 'dashboard'
@@ -531,10 +402,12 @@ export default function Logs() {
onExport={handleExport} onExport={handleExport}
canEdit={userPermissions.canEdit} canEdit={userPermissions.canEdit}
hasLogs={logs.length > 0} hasLogs={logs.length > 0}
onOpenNotificationSettings={handleOpenNotificationSettings} onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onSearchOpenChange={handleSearchOpenChange} onSearchOpenChange={(open: boolean) => {
isSearchOpenRef.current = open
}}
/> />
</div> </div>
@@ -576,7 +449,7 @@ export default function Logs() {
</div> </div>
{/* Table body - virtualized */} {/* Table body - virtualized */}
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
{logsQuery.isLoading && !logsQuery.data ? ( {logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'> <div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -603,7 +476,6 @@ export default function Logs() {
logs={logs} logs={logs}
selectedLogId={selectedLogId} selectedLogId={selectedLogId}
onLogClick={handleLogClick} onLogClick={handleLogClick}
onLogHover={handleLogHover}
onLogContextMenu={handleLogContextMenu} onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef} selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false} hasNextPage={logsQuery.hasNextPage ?? false}
@@ -639,7 +511,7 @@ export default function Logs() {
isOpen={contextMenuOpen} isOpen={contextMenuOpen}
position={contextMenuPosition} position={contextMenuPosition}
menuRef={contextMenuRef} menuRef={contextMenuRef}
onClose={handleCloseContextMenu} onClose={() => setContextMenuOpen(false)}
log={contextMenuLog} log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId} onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow} onOpenWorkflow={handleOpenWorkflow}
@@ -656,7 +528,10 @@ export default function Logs() {
traceSpans={activeLogQuery.data.executionData?.traceSpans} traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal isModal
isOpen={isPreviewOpen} isOpen={isPreviewOpen}
onClose={handleClosePreview} onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
/> />
)} )}
</div> </div>

View File

@@ -239,12 +239,7 @@ export const ComboBox = memo(function ComboBox({
*/ */
const defaultOptionValue = useMemo(() => { const defaultOptionValue = useMemo(() => {
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
// Validate that the default value exists in the available (filtered) options return defaultValue
const defaultInOptions = evaluatedOptions.find((opt) => getOptionValue(opt) === defaultValue)
if (defaultInOptions) {
return defaultValue
}
// Default not available (e.g. provider disabled) — fall through to other fallbacks
} }
// For model field, default to claude-sonnet-4-5 if available // For model field, default to claude-sonnet-4-5 if available

View File

@@ -223,12 +223,7 @@ function resolveToolsDisplay(
* - Resolves tool names from block registry * - Resolves tool names from block registry
* - Shows '-' for other selector types that need hydration * - Shows '-' for other selector types that need hydration
*/ */
const SubBlockRow = memo(function SubBlockRow({ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
title,
value,
subBlock,
rawValue,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -260,7 +255,7 @@ const SubBlockRow = memo(function SubBlockRow({
)} )}
</div> </div>
) )
}) }
/** /**
* Preview block component for workflow visualization. * Preview block component for workflow visualization.

View File

@@ -2,10 +2,11 @@ import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons' import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { getApiKeyCondition, getModelOptions } from '@/blocks/utils' import { getApiKeyCondition } from '@/blocks/utils'
import { import {
getBaseModelProviders, getBaseModelProviders,
getMaxTemperature, getMaxTemperature,
getProviderIcon,
getReasoningEffortValuesForModel, getReasoningEffortValuesForModel,
getThinkingLevelsForModel, getThinkingLevelsForModel,
getVerbosityValuesForModel, getVerbosityValuesForModel,
@@ -17,6 +18,7 @@ import {
providers, providers,
supportsTemperature, supportsTemperature,
} from '@/providers/utils' } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock') const logger = createLogger('AgentBlock')
@@ -119,7 +121,21 @@ Return ONLY the JSON array.`,
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
{ {
id: 'vertexCredential', id: 'vertexCredential',

View File

@@ -1,13 +1,10 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ChartBarIcon } from '@/components/icons' import { ChartBarIcon } from '@/components/icons'
import type { BlockConfig, ParamType } from '@/blocks/types' import type { BlockConfig, ParamType } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders } from '@/providers/utils' import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('EvaluatorBlock') const logger = createLogger('EvaluatorBlock')
@@ -178,7 +175,21 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -1,10 +1,8 @@
import { ShieldCheckIcon } from '@/components/icons' import { ShieldCheckIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions, import { getProviderIcon } from '@/providers/utils'
getProviderCredentialSubBlocks, import { useProvidersStore } from '@/stores/providers/store'
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
export interface GuardrailsResponse extends ToolResponse { export interface GuardrailsResponse extends ToolResponse {
@@ -113,7 +111,21 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
condition: { condition: {
field: 'validationType', field: 'validationType',
value: ['hallucination'], value: ['hallucination'],

View File

@@ -1,12 +1,9 @@
import { ConnectIcon } from '@/components/icons' import { ConnectIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders } from '@/providers/utils' import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
interface RouterResponse extends ToolResponse { interface RouterResponse extends ToolResponse {
@@ -137,6 +134,25 @@ Respond with a JSON object containing:
- reasoning: A brief explanation (1-2 sentences) of why you chose this route` - reasoning: A brief explanation (1-2 sentences) of why you chose this route`
} }
/**
* Helper to get model options for both router versions.
*/
const getModelOptions = () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/** /**
* Legacy Router Block (block-based routing). * Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows. * Hidden from toolbar but still supported for existing workflows.

View File

@@ -1,10 +1,8 @@
import { TranslateIcon } from '@/components/icons' import { TranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions, import { getProviderIcon } from '@/providers/utils'
getProviderCredentialSubBlocks, import { useProvidersStore } from '@/stores/providers/store'
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
const getTranslationPrompt = (targetLanguage: string) => const getTranslationPrompt = (targetLanguage: string) =>
`Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.`
@@ -40,7 +38,18 @@ export const TranslateBlock: BlockConfig = {
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -1,32 +1,8 @@
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
import { import { getHostedModels, getProviderFromModel, providers } from '@/providers/utils'
getHostedModels,
getProviderFromModel,
getProviderIcon,
providers,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store' import { useProvidersStore } from '@/stores/providers/store'
/**
* Returns model options for combobox subblocks, combining all provider sources.
*/
export function getModelOptions() {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/** /**
* Checks if a field is included in the dependsOn config. * Checks if a field is included in the dependsOn config.
* Handles both simple array format and object format with all/any fields. * Handles both simple array format and object format with all/any fields.

View File

@@ -1008,7 +1008,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
* Non-virtualized code viewer implementation. * Non-virtualized code viewer implementation.
* Renders all lines directly without windowing. * Renders all lines directly without windowing.
*/ */
const ViewerInner = memo(function ViewerInner({ function ViewerInner({
code, code,
showGutter, showGutter,
language, language,
@@ -1181,7 +1181,7 @@ const ViewerInner = memo(function ViewerInner({
</Content> </Content>
</Container> </Container>
) )
}) }
/** /**
* Readonly code viewer with optional gutter and syntax highlighting. * Readonly code viewer with optional gutter and syntax highlighting.

View File

@@ -1,10 +1,4 @@
import { import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
keepPreviousData,
type QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import type { import type {
@@ -152,45 +146,17 @@ export function useLogsList(
interface UseLogDetailOptions { interface UseLogDetailOptions {
enabled?: boolean enabled?: boolean
refetchInterval?: refetchInterval?: number | false
| number
| false
| ((query: { state: { data?: WorkflowLog } }) => number | false | undefined)
} }
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) { export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
const queryClient = useQueryClient()
return useQuery({ return useQuery({
queryKey: logKeys.detail(logId), queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string), queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId) && (options?.enabled ?? true), enabled: Boolean(logId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false, refetchInterval: options?.refetchInterval ?? false,
staleTime: 30 * 1000, staleTime: 30 * 1000,
initialData: () => { placeholderData: keepPreviousData,
if (!logId) return undefined
const listQueries = queryClient.getQueriesData<{
pages: { logs: WorkflowLog[] }[]
}>({
queryKey: logKeys.lists(),
})
for (const [, data] of listQueries) {
const match = data?.pages?.flatMap((p) => p.logs).find((l) => l.id === logId)
if (match) return match
}
return undefined
},
initialDataUpdatedAt: 0,
})
}
/**
* Prefetches log detail data on hover for instant panel rendering on click.
*/
export function prefetchLogDetail(queryClient: QueryClient, logId: string) {
queryClient.prefetchQuery({
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId),
staleTime: 30 * 1000,
}) })
} }

View File

@@ -30,8 +30,8 @@ export const vertexProvider: ProviderConfig = {
executeRequest: async ( executeRequest: async (
request: ProviderRequest request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => { ): Promise<ProviderResponse | StreamingExecution> => {
const vertexProject = request.vertexProject || env.VERTEX_PROJECT const vertexProject = env.VERTEX_PROJECT || request.vertexProject
const vertexLocation = request.vertexLocation || env.VERTEX_LOCATION || 'us-central1' const vertexLocation = env.VERTEX_LOCATION || request.vertexLocation || 'us-central1'
if (!vertexProject) { if (!vertexProject) {
throw new Error( throw new Error(