Compare commits

...

2 Commits

Author SHA1 Message Date
Shantur Rathore
da70cc9944 fix(ui): keep prompt attachments in sync 2026-02-13 00:51:42 +00:00
Shantur Rathore
ba418a8518 chore(release): publish dev builds as codenomad-dev
Switch dev workflow to publish the server under @neuralnomads/codenomad-dev with dist-tag latest, avoiding @dev dist-tags. Add workflow input to override package name at publish time.
2026-02-13 00:39:14 +00:00
10 changed files with 174 additions and 97 deletions

View File

@@ -34,7 +34,8 @@ jobs:
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
version_suffix: ${{ needs.prepare.outputs.version_suffix }} version_suffix: ${{ needs.prepare.outputs.version_suffix }}
dist_tag: dev npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest
prerelease: true prerelease: true
release_ui: false release_ui: false
secrets: inherit secrets: inherit

View File

@@ -12,6 +12,11 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
package_name:
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
required: false
default: "@neuralnomads/codenomad"
type: string
workflow_call: workflow_call:
inputs: inputs:
version: version:
@@ -21,6 +26,10 @@ on:
required: false required: false
type: string type: string
default: dev default: dev
package_name:
required: false
type: string
default: "@neuralnomads/codenomad"
secrets: secrets:
NPM_TOKEN: NPM_TOKEN:
required: false required: false
@@ -54,7 +63,7 @@ jobs:
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling) - name: Build server package (includes UI bundling)
run: npm run build --workspace @neuralnomads/codenomad run: npm run build --workspace packages/server
- name: Set publish metadata - name: Set publish metadata
shell: bash shell: bash
@@ -65,10 +74,17 @@ jobs:
fi fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV" echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV" echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
- name: Bump package version for publish - name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Set server package name for publish
shell: bash
run: |
set -euo pipefail
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
- name: Publish server package with provenance - name: Publish server package with provenance
env: env:
# Optional: when present, npm will use token auth. # Optional: when present, npm will use token auth.
@@ -85,4 +101,4 @@ jobs:
else else
echo "Using NPM_TOKEN authentication" echo "Using NPM_TOKEN authentication"
fi fi
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance

View File

@@ -14,4 +14,5 @@ jobs:
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
dist_tag: latest dist_tag: latest
npm_package_name: "@neuralnomads/codenomad"
secrets: inherit secrets: inherit

View File

@@ -13,6 +13,11 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
npm_package_name:
description: "npm package name to publish (defaults to server package name)"
required: false
default: ""
type: string
prerelease: prerelease:
description: "Create GitHub prerelease" description: "Create GitHub prerelease"
required: false required: false
@@ -100,4 +105,5 @@ jobs:
with: with:
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }}
secrets: inherit secrets: inherit

View File

@@ -47,7 +47,7 @@ npx @neuralnomads/codenomad --launch
For dev version For dev version
```bash ```bash
npx @neuralnomads/codenomad@dev --launch npx @neuralnomads/codenomad-dev --launch
``` ```
Dev builds are published as GitHub pre-releases: Dev builds are published as GitHub pre-releases:

View File

@@ -1,8 +1,8 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
handleDrop, handleDrop,
syncAttachmentCounters, syncAttachmentCounters,
handleExpandTextAttachment, handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({ } = usePromptAttachments({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
if (!attachment) return if (!attachment) return
handleExpandTextAttachment(attachment) handleExpandTextAttachment(attachment)
}, },
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => { setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef const textarea = textareaRef
if (textarea) { if (textarea) {
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null) setAtPosition(null)
setSearchQuery("") setSearchQuery("")
const instanceId = props.instanceId syncAttachmentCounters(prompt())
const sessionId = props.sessionId
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
syncAttachmentCounters(prompt(), currentAttachments)
}, },
{ defer: true }, { defer: true },
), ),
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
// Ignore attachments for slash commands, but keep them for next prompt. // Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) { if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId) clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("", []) syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
syncAttachmentCounters("", currentAttachments) syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }

View File

@@ -1,5 +1,3 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) { export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]` return `[pasted #${value}]`
} }
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
} }
export function createPastedPlaceholderRegex() { export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
} }
export function createImagePlaceholderRegex() { export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
} }
export function createMentionRegex() { export function createMentionRegex() {
return /@(\S+)/g return /@(\S+)/g
} }
export const pastedDisplayCounterRegex = /pasted #(\d+)/ export const pastedDisplayCounterRegex = /pasted #(\d+)/i
export const imageDisplayCounterRegex = /Image #(\d+)/ export const imageDisplayCounterRegex = /Image #(\d+)/i
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/ export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
export function parseCounter(value: string) { export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed return Number.isNaN(parsed) ? null : parsed
} }
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { export function findHighestAttachmentCounters(currentPrompt: string) {
let highestPaste = 0 let highestPaste = 0
let highestImage = 0 let highestImage = 0
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
} }
} }
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1]) const parsed = parseCounter(match[1])
if (parsed !== null) { if (parsed !== null) {

View File

@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi { export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void focus(): void
} }

View File

@@ -1,4 +1,4 @@
import { createSignal, type Accessor } from "solid-js" import { createEffect, createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment" import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
@@ -7,6 +7,7 @@ import {
findHighestAttachmentCounters, findHighestAttachmentCounters,
formatImagePlaceholder, formatImagePlaceholder,
formatPastedPlaceholder, formatPastedPlaceholder,
imageDisplayCounterRegex,
pastedDisplayCounterRegex, pastedDisplayCounterRegex,
} from "./attachmentPlaceholders" } from "./attachmentPlaceholders"
@@ -23,7 +24,7 @@ type PromptAttachments = {
attachments: Accessor<Attachment[]> attachments: Accessor<Attachment[]>
pasteCount: Accessor<number> pasteCount: Accessor<number>
imageCount: Accessor<number> imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void syncAttachmentCounters: (promptText: string) => void
handlePaste: (e: ClipboardEvent) => Promise<void> handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean> isDragging: Accessor<boolean>
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const [pasteCount, setPasteCount] = createSignal(0) const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { function syncAttachmentCounters(currentPrompt: string) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
setPasteCount(highestPaste) setPasteCount(highestPaste)
setImageCount(highestImage) setImageCount(highestImage)
} }
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
const next = currentPrompt.replace(tokenRegex, "")
if (next === currentPrompt) return currentPrompt
return next
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.trim()
}
const createLooseImagePlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
// Keep placeholder-backed attachments in sync with prompt text.
// If the placeholder token disappears from the prompt, the attachment should disappear too.
createEffect(() => {
const currentPrompt = options.prompt()
const currentAttachments = attachments()
const toRemove: string[] = []
for (const attachment of currentAttachments) {
if (attachment.source.type === "text") {
const match = attachment.display.match(pastedDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
continue
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const match =
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
}
}
for (const attachmentId of toRemove) {
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
}
})
function handleRemoveAttachment(attachmentId: string) { function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments() const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId) const attachment = currentAttachments.find((a) => a.id === attachmentId)
// Always remove from store.
removeAttachment(options.instanceId(), options.sessionId(), attachmentId) removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (attachment) { if (!attachment) return
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
if (attachment.source.type === "file") { const currentPrompt = options.prompt()
if (attachment.mediaType.startsWith("image/")) { let nextPrompt = currentPrompt
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (imageMatch) { if (attachment.source.type === "file") {
const placeholder = formatImagePlaceholder(imageMatch[1]) if (attachment.mediaType.startsWith("image/")) {
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() const imageMatch =
} attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
} else { if (imageMatch) {
const filename = attachment.filename nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
} }
} else if (attachment.source.type === "agent") { } else {
const agentName = attachment.filename // For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() const candidates = [attachment.source.path, attachment.filename]
} else if (attachment.source.type === "text") { for (const candidate of candidates) {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) if (!candidate) continue
if (placeholderMatch) { const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
const placeholder = formatPastedPlaceholder(placeholderMatch[1]) nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
} }
} }
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
}
}
options.setPrompt(newPrompt) if (nextPrompt !== currentPrompt) {
options.setPrompt(nextPrompt)
} }
} }
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const blob = item.getAsFile() const blob = item.getAsFile()
if (!blob) continue if (!blob) continue
const count = imageCount() + 1 const { highestImage } = findHighestAttachmentCounters(options.prompt())
const count = highestImage + 1
setImageCount(count) setImageCount(count)
const placeholder = formatImagePlaceholder(count)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
}
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1] const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png` const filename = `image-${count}.png`
const attachment = createFileAttachment( const attachment = createFileAttachment(
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
options.instanceFolder(), options.instanceFolder(),
) )
attachment.url = `data:image/png;base64,${base64Data}` attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = display attachment.display = placeholder
addAttachment(options.instanceId(), options.sessionId(), attachment) addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
} }
reader.readAsDataURL(blob) reader.readAsDataURL(blob)
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
if (isLongPaste) { if (isLongPaste) {
e.preventDefault() e.preventDefault()
const count = pasteCount() + 1 const { highestPaste } = findHighestAttachmentCounters(options.prompt())
const count = highestPaste + 1
setPasteCount(count) setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const filename = `paste-${count}.txt` const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename) const attachment = createTextAttachment(pastedText, display, filename)
addAttachment(options.instanceId(), options.sessionId(), attachment) const placeholder = formatPastedPlaceholder(count)
const textarea = options.getTextarea() const textarea = options.getTextarea()
if (textarea) { if (textarea) {
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const currentText = options.prompt() const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText) options.setPrompt(newText)
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus() textarea.focus()
}, 0) }, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
} }
addAttachment(options.instanceId(), options.sessionId(), attachment)
} }
} }

View File

@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/> />
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<PromptAttachmentsBar <PromptAttachmentsBar
attachments={attachments()} attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)} onRemoveAttachment={(attachmentId) => {
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} if (promptInputApi) {
/> promptInputApi.removeAttachment(attachmentId)
</Show> return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}