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
42 changes: 42 additions & 0 deletions apps/webclaw/e2e/table-block.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'

test('table block edit/import/insert and persistence', async ({ page }) => {
await page.goto('/dev/table-block')
await page.evaluate(() => {
window.localStorage.removeItem('chat-table-workbench-blocks-v1')
})
await page.reload()

await page.getByRole('button', { name: 'Add Table Block' }).click()

await page.getByRole('button', { name: 'Empty' }).first().click()
const editor = page.locator('table input').first()
await editor.fill('alpha')
await editor.press('Enter')

await expect(page.getByRole('button', { name: 'alpha' })).toBeVisible()

await page.getByRole('button', { name: 'Import CSV' }).click()
await page.locator('textarea').first().fill('name,age\nAlice,30\nBob,29')
await page.getByRole('button', { name: 'Apply import' }).click()

await expect(
page.getByRole('button', { name: 'name', exact: true }),
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Alice', exact: true }),
).toBeVisible()

await page.getByRole('button', { name: 'Insert to Prompt' }).click()

await expect(page.getByTestId('prompt-target')).toContainText('| name | age |')

await page.reload()

await expect(
page.getByRole('button', { name: 'name', exact: true }),
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Alice', exact: true }),
).toBeVisible()
})
2 changes: 2 additions & 0 deletions apps/webclaw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix"
Expand Down Expand Up @@ -41,6 +42,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
Expand Down
17 changes: 17 additions & 0 deletions apps/webclaw/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test'

export default defineConfig({
testDir: './e2e',
timeout: 45_000,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:3200',
headless: true,
},
webServer: {
command: 'pnpm build && pnpm exec vite preview --port 3200 --host 127.0.0.1',
url: 'http://127.0.0.1:3200',
reuseExistingServer: false,
timeout: 240_000,
},
})
21 changes: 21 additions & 0 deletions apps/webclaw/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as NewRouteImport } from './routes/new'
import { Route as ConnectRouteImport } from './routes/connect'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevTableBlockRouteImport } from './routes/dev.table-block'
import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey'
import { Route as ApiStreamRouteImport } from './routes/api/stream'
import { Route as ApiSessionsRouteImport } from './routes/api/sessions'
Expand All @@ -35,6 +36,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DevTableBlockRoute = DevTableBlockRouteImport.update({
id: '/dev/table-block',
path: '/dev/table-block',
getParentRoute: () => rootRouteImport,
} as any)
const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({
id: '/chat/$sessionKey',
path: '/chat/$sessionKey',
Expand Down Expand Up @@ -82,6 +88,7 @@ export interface FileRoutesByFullPath {
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/dev/table-block': typeof DevTableBlockRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
Expand All @@ -94,6 +101,7 @@ export interface FileRoutesByTo {
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/dev/table-block': typeof DevTableBlockRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
Expand All @@ -107,6 +115,7 @@ export interface FileRoutesById {
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
'/dev/table-block': typeof DevTableBlockRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
Expand All @@ -121,6 +130,7 @@ export interface FileRouteTypes {
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
| '/dev/table-block'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
Expand All @@ -133,6 +143,7 @@ export interface FileRouteTypes {
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
| '/dev/table-block'
id:
| '__root__'
| '/'
Expand All @@ -145,6 +156,7 @@ export interface FileRouteTypes {
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
| '/dev/table-block'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
Expand All @@ -158,6 +170,7 @@ export interface RootRouteChildren {
ApiSessionsRoute: typeof ApiSessionsRoute
ApiStreamRoute: typeof ApiStreamRoute
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
DevTableBlockRoute: typeof DevTableBlockRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -183,6 +196,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/table-block': {
id: '/dev/table-block'
path: '/dev/table-block'
fullPath: '/dev/table-block'
preLoaderRoute: typeof DevTableBlockRouteImport
parentRoute: typeof rootRouteImport
}
'/chat/$sessionKey': {
id: '/chat/$sessionKey'
path: '/chat/$sessionKey'
Expand Down Expand Up @@ -246,6 +266,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSessionsRoute: ApiSessionsRoute,
ApiStreamRoute: ApiStreamRoute,
ChatSessionKeyRoute: ChatSessionKeyRoute,
DevTableBlockRoute: DevTableBlockRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
47 changes: 47 additions & 0 deletions apps/webclaw/src/routes/dev.table-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'

import { ChatWorkbench } from '@/screens/chat/components/chat-workbench'

export const Route = createFileRoute('/dev/table-block')({
component: RouteComponent,
})

function RouteComponent() {
const [promptValue, setPromptValue] = useState('')

return (
<main className="min-h-screen bg-surface text-primary-900">
<div className="mx-auto max-w-[960px] px-5 py-6">
<h1 className="text-xl font-medium text-primary-900">Table Block E2E</h1>
<p className="mt-1 text-sm text-primary-700">
Dev page for browser-level table block verification.
</p>
</div>

<ChatWorkbench
sessionKey="dev-table-block-e2e"
onInsertToPrompt={(markdown) => {
const trimmed = markdown.trim()
if (trimmed.length === 0) return
setPromptValue((prev) =>
prev.trim().length > 0 ? `${prev.trimEnd()}\n\n${trimmed}` : trimmed,
)
}}
/>

<div className="mx-auto max-w-[960px] px-5 pb-6">
<label className="block text-sm font-medium text-primary-900" htmlFor="prompt-target">
Prompt Target
</label>
<textarea
id="prompt-target"
data-testid="prompt-target"
className="mt-2 h-36 w-full rounded-lg border border-primary-200 bg-surface px-3 py-2 text-sm text-primary-900"
readOnly
value={promptValue}
/>
</div>
</main>
)
}
28 changes: 25 additions & 3 deletions apps/webclaw/src/screens/chat/chat-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ChatHeader } from './components/chat-header'
import { ChatMessageList } from './components/chat-message-list'
import { ChatComposer } from './components/chat-composer'
import { GatewayStatusMessage } from './components/gateway-status-message'
import { ChatWorkbench } from './components/chat-workbench'
import {
hasPendingGeneration,
hasPendingSend,
Expand All @@ -42,7 +43,10 @@ import { useChatGenerationGuard } from './hooks/use-chat-generation-guard'
import { shouldRedirectToConnect } from './hooks/use-chat-error-state'
import { useChatRedirect } from './hooks/use-chat-redirect'
import type { AttachmentFile } from '@/components/attachment-button'
import type { ChatComposerHelpers } from './components/chat-composer'
import type {
ChatComposerApi,
ChatComposerHelpers,
} from './components/chat-composer'
import { useExport } from '@/hooks/use-export'
import { useChatSettings } from '@/hooks/use-chat-settings'
import { cn, randomUUID } from '@/lib/utils'
Expand All @@ -68,8 +72,15 @@ export function ChatScreen({
const [sending, setSending] = useState(false)
const [creatingSession, setCreatingSession] = useState(false)
const [isRedirecting, setIsRedirecting] = useState(false)
const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } =
useChatMeasurements()
const composerApiRef = useRef<ChatComposerApi | null>(null)
const {
headerRef,
composerRef,
workbenchRef,
mainRef,
pinGroupMinHeight,
headerHeight,
} = useChatMeasurements()
const [waitingForResponse, setWaitingForResponse] = useState(
() => hasPendingSend() || hasPendingGeneration(),
)
Expand Down Expand Up @@ -465,6 +476,11 @@ export function ChatScreen({
gatewayStatusError,
})
const historyEmpty = !historyLoading && displayMessages.length === 0
const workbenchSessionKey =
activeCanonicalKey || sessionKeyForHistory || activeFriendlyId || 'new'
const handleInsertPromptFromBlock = useCallback((markdown: string) => {
composerApiRef.current?.appendToPrompt(markdown)
}, [])
const gatewayNotice = useMemo(() => {
if (!showGatewayNotice) return null
if (!gatewayError) return null
Expand Down Expand Up @@ -613,11 +629,17 @@ export function ChatScreen({
headerHeight={headerHeight}
contentStyle={stableContentStyle}
/>
<ChatWorkbench
sessionKey={workbenchSessionKey}
onInsertToPrompt={handleInsertPromptFromBlock}
wrapperRef={workbenchRef}
/>
<ChatComposer
onSubmit={send}
isLoading={sending}
disabled={sending}
wrapperRef={composerRef}
apiRef={composerApiRef}
/>
</>
)}
Expand Down
28 changes: 26 additions & 2 deletions apps/webclaw/src/screens/chat/components/chat-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo, useCallback, useRef, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowUp02Icon } from '@hugeicons/core-free-icons'
import type { Ref } from 'react'
import type { Ref, RefObject } from 'react'

import type { AttachmentFile } from '@/components/attachment-button'
import {
Expand All @@ -21,6 +21,7 @@ type ChatComposerProps = {
isLoading: boolean
disabled: boolean
wrapperRef?: Ref<HTMLDivElement>
apiRef?: RefObject<ChatComposerApi | null>
}

type ChatComposerHelpers = {
Expand All @@ -29,11 +30,17 @@ type ChatComposerHelpers = {
attachments?: Array<AttachmentFile>
}

type ChatComposerApi = {
setValue: (value: string) => void
appendToPrompt: (markdown: string) => void
}

function ChatComposerComponent({
onSubmit,
isLoading,
disabled,
wrapperRef,
apiRef,
}: ChatComposerProps) {
const [attachments, setAttachments] = useState<Array<AttachmentFile>>([])
const promptRef = useRef<HTMLTextAreaElement | null>(null)
Expand Down Expand Up @@ -80,6 +87,23 @@ function ChatComposerComponent({
},
[focusPrompt],
)
const appendToPrompt = useCallback(
(markdown: string) => {
const trimmedMarkdown = markdown.trim()
if (trimmedMarkdown.length === 0) return
const current = valueRef.current.trimEnd()
const nextValue =
current.length > 0 ? `${current}\n\n${trimmedMarkdown}` : trimmedMarkdown
setComposerValue(nextValue)
},
[setComposerValue],
)
if (apiRef) {
apiRef.current = {
setValue: setComposerValue,
appendToPrompt,
}
}
const handleSubmit = useCallback(() => {
if (disabled) return
const body = valueRef.current.trim()
Expand Down Expand Up @@ -165,4 +189,4 @@ function ChatComposerComponent({
const MemoizedChatComposer = memo(ChatComposerComponent)

export { MemoizedChatComposer as ChatComposer }
export type { ChatComposerHelpers }
export type { ChatComposerApi, ChatComposerHelpers }
Loading