Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,10 @@ export function Chat() {
const { addToQueue } = useOperationQueue()

const [chatMessage, setChatMessage] = useState('')
const [promptHistory, setPromptHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
const [moreMenuOpen, setMoreMenuOpen] = useState(false)

const promptHistoryRef = useRef<string[]>([])
const historyIndexRef = useRef(-1)
const inputRef = useRef<HTMLInputElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
Expand All @@ -292,22 +292,26 @@ export function Chat() {
handleDrop,
} = useChatFileUpload()

const filePreviewUrls = useRef<Map<string, string>>(new Map())
const filePreviewUrls = useRef<Map<string, string> | null>(null)
if (filePreviewUrls.current === null) {
filePreviewUrls.current = new Map()
}

const getFilePreviewUrl = useCallback((file: ChatFile): string | null => {
if (!file.type.startsWith('image/')) return null

const existing = filePreviewUrls.current.get(file.id)
const urls = (filePreviewUrls.current ??= new Map())
const existing = urls.get(file.id)
if (existing) return existing

const url = URL.createObjectURL(file.file)
filePreviewUrls.current.set(file.id, url)
urls.set(file.id, url)
return url
}, [])

useEffect(() => {
const currentFileIds = new Set(chatFiles.map((f) => f.id))
const urlMap = filePreviewUrls.current
const urlMap = (filePreviewUrls.current ??= new Map())

for (const [fileId, url] of urlMap.entries()) {
if (!currentFileIds.has(fileId)) {
Expand Down Expand Up @@ -454,21 +458,24 @@ export function Chat() {
)

const userMessages = useMemo(() => {
return workflowMessages
.filter((msg) => msg.type === 'user')
.map((msg) => msg.content)
.filter((content): content is string => typeof content === 'string')
const result: string[] = []
for (const msg of workflowMessages) {
if (msg.type === 'user' && typeof msg.content === 'string') {
result.push(msg.content)
}
}
return result
}, [workflowMessages])

useEffect(() => {
if (!activeWorkflowId) {
setPromptHistory([])
setHistoryIndex(-1)
promptHistoryRef.current = []
historyIndexRef.current = -1
return
}

setPromptHistory(userMessages)
setHistoryIndex(-1)
promptHistoryRef.current = userMessages
historyIndexRef.current = -1
}, [activeWorkflowId, userMessages])

/**
Expand Down Expand Up @@ -607,7 +614,7 @@ export function Chat() {
focusInput(100)
}
},
[appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
[appendMessageContent, finalizeMessageStream, focusInput]
)

/**
Expand All @@ -633,19 +640,18 @@ export function Chat() {
}

if ('success' in result && result.success && 'logs' in result && Array.isArray(result.logs)) {
selectedOutputs
.map((outputId) => extractOutputFromLogs(result.logs as BlockLog[], outputId))
.filter((output) => output !== undefined)
.forEach((output) => {
const content = formatOutputContent(output)
if (content) {
addMessage({
content,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
})
for (const outputId of selectedOutputs) {
const output = extractOutputFromLogs(result.logs as BlockLog[], outputId)
if (output === undefined) continue
const content = formatOutputContent(output)
if (content) {
addMessage({
content,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
}
return
}

Expand Down Expand Up @@ -674,10 +680,13 @@ export function Chat() {

const sentMessage = chatMessage.trim()

if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
setPromptHistory((prev) => [...prev, sentMessage])
if (
sentMessage &&
promptHistoryRef.current[promptHistoryRef.current.length - 1] !== sentMessage
) {
promptHistoryRef.current = [...promptHistoryRef.current, sentMessage]
}
setHistoryIndex(-1)
historyIndexRef.current = -1

const conversationId = getConversationId(activeWorkflowId)

Expand Down Expand Up @@ -732,7 +741,6 @@ export function Chat() {
chatFiles,
activeWorkflowId,
isExecuting,
promptHistory,
getConversationId,
addMessage,
handleRunWorkflow,
Expand All @@ -756,27 +764,29 @@ export function Chat() {
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (promptHistory.length > 0) {
if (promptHistoryRef.current.length > 0) {
const newIndex =
historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
historyIndexRef.current === -1
? promptHistoryRef.current.length - 1
: Math.max(0, historyIndexRef.current - 1)
historyIndexRef.current = newIndex
setChatMessage(promptHistoryRef.current[newIndex])
}
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIndex >= 0) {
const newIndex = historyIndex + 1
if (newIndex >= promptHistory.length) {
setHistoryIndex(-1)
if (historyIndexRef.current >= 0) {
const newIndex = historyIndexRef.current + 1
if (newIndex >= promptHistoryRef.current.length) {
historyIndexRef.current = -1
setChatMessage('')
} else {
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
historyIndexRef.current = newIndex
setChatMessage(promptHistoryRef.current[newIndex])
}
}
}
},
[handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting]
[handleSendMessage, isStreaming, isExecuting]
)

/**
Expand Down Expand Up @@ -1085,7 +1095,7 @@ export function Chat() {
value={chatMessage}
onChange={(e) => {
setChatMessage(e.target.value)
setHistoryIndex(-1)
historyIndexRef.current = -1
}}
onKeyDown={handleKeyPress}
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
Expand Down Expand Up @@ -1122,6 +1132,7 @@ export function Chat() {
) : (
<Button
onClick={handleSendMessage}
aria-label='Send message'
variant='ghost'
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ const TagIcon: React.FC<{

const EMPTY_OUTPUTS: string[] = []

/**
* Gets the background color for a block output based on its type
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const getOutputColor = (blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}

/**
* Props for the OutputSelect component
*/
Expand Down Expand Up @@ -159,16 +169,7 @@ export function OutputSelect({
path: f.path,
}
})
}, [
workflowBlocks,
workflowId,
isShowingDiff,
isDiffReady,
baselineWorkflow,
blocks,
subBlockValues,
shouldUseBaseline,
])
}, [workflowBlocks, workflowId, baselineWorkflow, subBlockValues, shouldUseBaseline])

/**
* Gets display text for selected outputs
Expand All @@ -193,16 +194,6 @@ export function OutputSelect({
return `${validOutputs.length} outputs`
}, [selectedOutputs, workflowOutputs, placeholder])

/**
* Gets the background color for a block output based on its type
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const getOutputColor = (blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}

/**
* Groups outputs by block and sorts by distance from starter block.
* Returns ComboboxOptionGroup[] for use with Combobox.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ const CursorsComponent = () => {
return []
}

return presenceUsers
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
.filter((user) => user.socketId !== currentSocketId)
.map((user) => ({
const rendered: CursorRenderData[] = []
for (const user of presenceUsers) {
if (!user.cursor || user.socketId === currentSocketId) continue
rendered.push({
id: user.socketId,
name: user.userName?.trim() || 'Collaborator',
cursor: user.cursor,
color: getUserColor(user.userId),
}))
})
}
return rendered
}, [activeWorkflowId, currentSocketId, currentWorkflowId, presenceUsers])

if (!cursors.length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const DiffControls = memo(function DiffControls() {
>
{/* Reject side */}
<button
type='button'
onClick={handleReject}
title='Reject changes'
className='relative flex h-full items-center border border-[var(--border)] bg-[var(--surface-4)] pr-5 pl-3 font-medium text-[var(--text-secondary)] text-small transition-colors hover-hover:border-[var(--border-1)] hover-hover:bg-[var(--surface-6)] hover-hover:text-[var(--text-primary)] dark:hover-hover:bg-[var(--surface-5)]'
Expand All @@ -86,6 +87,7 @@ export const DiffControls = memo(function DiffControls() {
/>
{/* Accept side */}
<button
type='button'
onClick={handleAccept}
title='Accept changes (⇧⌘⏎)'
className='-ml-2.5 relative flex h-full items-center border border-[rgba(0,0,0,0.15)] bg-[var(--brand-accent)] pr-3 pl-5 font-medium text-[var(--text-inverse)] text-small transition-[background-color,border-color,fill,stroke] hover-hover:brightness-110 dark:border-[rgba(255,255,255,0.1)]'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const NoteBlock = memo(function NoteBlock({
const userPermissions = useUserPermissionsContext()
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked

const actionBar = useMemo(
() => <ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />,
[id, type, canEditWorkflow]
)

/**
* Calculate deterministic dimensions based on content structure. Uses fixed
* width and computed height to avoid ResizeObserver jitter.
Expand All @@ -90,7 +95,7 @@ export const NoteBlock = memo(function NoteBlock({
hasRing={hasRing}
ringStyles={ringStyles}
onSelect={handleClick}
actionBar={<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />}
actionBar={actionBar}
/>
)
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
escapeRegex,
filterOutContext,
Expand Down Expand Up @@ -56,40 +56,45 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
/**
* Synchronizes selected contexts with inline @label or /label tokens in the message.
* Removes contexts whose labels are no longer present in the message.
*
* Adjusted during render with the prev-ref idiom rather than in an effect so the
* stale, pre-filtered list is never committed. The ref starts as `undefined` so the
* very first render runs the same sync the mounting effect used to.
*/
useEffect(() => {
const prevMessageRef = useRef<string | undefined>(undefined)
if (prevMessageRef.current !== message) {
prevMessageRef.current = message
if (!message) {
// Functional updater bails out when already empty; a fresh `[]` literal would
// emit a new reference and invalidate downstream memos that key on identity.
setSelectedContexts((prev) => (prev.length === 0 ? prev : []))
return
}

setSelectedContexts((prev) => {
if (prev.length === 0) return prev
} else {
setSelectedContexts((prev) => {
if (prev.length === 0) return prev

const filtered = prev.filter((c) => {
if (!c.label) return false
// Check for slash command tokens or mention tokens based on kind.
// The trailing lookahead `(?![A-Za-z0-9_])` accepts any word-boundary
// — whitespace, end-of-string, or punctuation — so `@Slack.` and
// `@Slack,` survive the sync. A strict `(\s|$)` here would strip
// contexts whenever the user ends a sentence with a mention.
// Skills store a wide EM SPACE sentinel (SKILL_CHIP_TRIGGER) as their
// trigger so the chip icon fits; slash commands keep '/'; everything
// else uses '@'. The sentinel is itself a whitespace character, but the
// `(^|\s)` boundary still matches the (regular) space or start that
// precedes it, then the literal sentinel.
const prefix =
c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@'
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])`
)
return tokenPattern.test(message)
const filtered = prev.filter((c) => {
if (!c.label) return false
// Check for slash command tokens or mention tokens based on kind.
// The trailing lookahead `(?![A-Za-z0-9_])` accepts any word-boundary
// — whitespace, end-of-string, or punctuation — so `@Slack.` and
// `@Slack,` survive the sync. A strict `(\s|$)` here would strip
// contexts whenever the user ends a sentence with a mention.
// Skills store a wide EM SPACE sentinel (SKILL_CHIP_TRIGGER) as their
// trigger so the chip icon fits; slash commands keep '/'; everything
// else uses '@'. The sentinel is itself a whitespace character, but the
// `(^|\s)` boundary still matches the (regular) space or start that
// precedes it, then the literal sentinel.
const prefix =
c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@'
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])`
)
return tokenPattern.test(message)
})
return filtered.length === prev.length ? prev : filtered
})
return filtered.length === prev.length ? prev : filtered
})
}, [message])
}
}

return {
selectedContexts,
Expand Down
Loading
Loading