Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/sidebar-ux-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@surf-kit/agent": patch
"@surf-kit/core": patch
---

Improve conversation history sidebar styling with semantic colour tokens and edge-to-edge layout
5 changes: 5 additions & 0 deletions .changeset/stop-generation-autoscroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@surf-kit/agent": minor
---

Add stop generation button and improve message thread auto-scroll behaviour
140 changes: 140 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
stages:
- lint
- test
- build
- deploy

workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_PIPELINE_SOURCE == "web"'

default:
interruptible: true
image: node:20
before_script:
- corepack enable pnpm
- pnpm install --frozen-lockfile
cache:
key:
files: [pnpm-lock.yaml]
paths:
- .pnpm-store/
policy: pull-push

variables:
PNPM_STORE_DIR: ".pnpm-store"

# --- CI checks ---

sk-lint:
stage: lint
script:
- pnpm lint

sk-typecheck:
stage: lint
script:
- pnpm typecheck

sk-test:
stage: test
needs: []
script:
- pnpm test

sk-build:
stage: build
needs: []
script:
- pnpm build
cache:
- key:
files: [pnpm-lock.yaml]
paths:
- .pnpm-store/
policy: pull
- key: turbo-$CI_COMMIT_SHA
paths:
- .turbo/
fallback_keys:
- turbo-
policy: pull-push

# --- GitLab Pages (Storybook + Playground) ---

pages:
stage: deploy
interruptible: false
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes: [packages/**, apps/storybook/**, apps/playground/**, .gitlab-ci.yml]
- if: '$CI_PIPELINE_SOURCE == "web"'
needs: [sk-build]
script:
- pnpm build --filter="!@surf-kit/storybook" --filter="!docs" --filter="!playground"
- pnpm --filter @surf-kit/storybook build
- pnpm --filter playground build
- mkdir -p public/storybook public/playground
- cp -r apps/storybook/dist/* public/storybook/
- cp -r apps/playground/dist/* public/playground/
artifacts:
paths:
- public

# --- Changesets: version PR or npm publish (mirrors changesets/action) ---

sk-release:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [sk-build, sk-test, sk-lint, sk-typecheck]
interruptible: false
variables:
GIT_DEPTH: 0
NPM_TOKEN: "$NPM_TOKEN"
NODE_AUTH_TOKEN: "$NPM_TOKEN"
RELEASE_BRANCH: "chore/version-packages"
MR_TITLE: "chore: version packages"
script:
- echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
- |
# Check for pending changesets
if pnpm changeset status 2>&1 | grep -q "No changesets present"; then
echo "No pending changesets — publishing to npm"
pnpm build
pnpm changeset publish
else
echo "Pending changesets found — creating/updating version MR"
git config user.name "gitlab-ci"
git config user.email "ci@gitlab.com"

# Create or reset the release branch from current main
git checkout -B "$RELEASE_BRANCH"

# Run changeset version to bump packages and generate changelogs
pnpm changeset version
pnpm install --no-frozen-lockfile

# Commit and push
git add -A
git commit -m "$MR_TITLE" || { echo "Nothing to commit"; exit 0; }
git remote set-url origin "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
git push --force origin "$RELEASE_BRANCH"

# Create or update the merge request via GitLab API
EXISTING_MR=$(curl -s --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests?source_branch=${RELEASE_BRANCH}&state=opened" \
| python3 -c "import sys,json; mrs=json.load(sys.stdin); print(mrs[0]['iid'] if mrs else '')")

if [ -n "$EXISTING_MR" ]; then
echo "Updating existing MR !${EXISTING_MR}"
else
curl -s --request POST --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--header "Content-Type: application/json" \
--data "{\"source_branch\":\"${RELEASE_BRANCH}\",\"target_branch\":\"main\",\"title\":\"${MR_TITLE}\",\"remove_source_branch\":true}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests" \
| python3 -c "import sys,json; mr=json.load(sys.stdin); print(f'Created MR !{mr[\"iid\"]}')"
fi
fi
2 changes: 1 addition & 1 deletion packages/agent/src/chat/AgentChat/AgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function AgentChat({
/>
)}

<MessageComposer onSend={handleSend} isLoading={state.isLoading} />
<MessageComposer onSend={handleSend} onStop={actions.stop} isLoading={state.isLoading} />
</div>
)
}
Expand Down
26 changes: 14 additions & 12 deletions packages/agent/src/chat/ConversationList/ConversationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ function ConversationList({
return (
<nav
aria-label="Conversation list"
className={twMerge('flex flex-col h-full bg-canvas', className)}
className={twMerge('flex flex-col flex-1 min-h-0', className)}
>
{onNew && (
<div className="p-3 border-b border-border">
<div className="px-5 pt-1 pb-3 border-b border-border">
<button
type="button"
onClick={onNew}
className="w-full px-4 py-2.5 rounded-xl text-sm font-semibold bg-accent text-white hover:bg-accent-hover transition-all duration-200"
className="w-full px-4 py-2 rounded-lg text-sm font-medium border border-border text-text-secondary hover:text-text-primary hover:bg-surface hover:border-border-strong transition-colors duration-150"
>
New conversation
</button>
Expand All @@ -43,21 +43,23 @@ function ConversationList({
<li
key={conversation.id}
className={twMerge(
'flex items-start border-b border-border transition-colors duration-200',
'hover:bg-surface',
isActive && 'bg-surface-raised border-l-2 border-l-accent',
'flex items-start transition-colors duration-150',
'hover:bg-surface-raised',
isActive
? 'bg-accent-subtlest border-l-[3px] border-l-accent'
: 'border-l-[3px] border-l-transparent',
)}
>
<button
type="button"
onClick={() => onSelect(conversation.id)}
aria-current={isActive ? 'true' : undefined}
className="flex-1 min-w-0 text-left px-4 py-3"
className="flex-1 min-w-0 text-left px-5 py-2.5"
>
<div className="text-sm font-medium text-brand-cream truncate">
<div className="text-sm font-medium text-text-primary truncate">
{conversation.title}
</div>
<div className="text-xs text-brand-cream/40 truncate mt-0.5 leading-relaxed">
<div className="text-xs text-text-muted truncate mt-0.5 leading-relaxed">
{conversation.lastMessage}
</div>
</button>
Expand All @@ -66,7 +68,7 @@ function ConversationList({
type="button"
onClick={() => onDelete(conversation.id)}
aria-label={`Delete ${conversation.title}`}
className="shrink-0 p-1.5 m-2 rounded-lg text-brand-cream/25 hover:text-brand-watermelon hover:bg-brand-watermelon/10 transition-colors duration-200"
className="shrink-0 p-1.5 m-2 rounded-lg text-text-muted hover:text-status-error hover:bg-status-error/10 transition-colors duration-150"
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand All @@ -90,8 +92,8 @@ function ConversationList({
})}

{conversations.length === 0 && (
<li className="px-4 py-8 text-center">
<span className="text-sm text-brand-cream/30 font-body">No conversations yet</span>
<li className="px-5 py-12 text-center">
<span className="text-sm text-text-muted font-body">No conversations yet</span>
</li>
)}
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('ConversationList', () => {
expect(activeButton?.getAttribute('aria-current')).toBe('true')
// The parent li should have the active background
const activeLi = activeButton?.closest('li')
expect(activeLi?.className).toContain('bg-surface-raised')
expect(activeLi?.className).toContain('bg-accent-subtlest')
expect(activeLi?.className).toContain('border-l-accent')
})

Expand Down
10 changes: 6 additions & 4 deletions packages/agent/src/chat/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const MAX_ATTACHMENTS = 5

export type MessageComposerProps = {
onSend: (content: string, attachments?: Attachment[]) => void
onStop?: () => void
isLoading?: boolean
placeholder?: string
className?: string
Expand Down Expand Up @@ -141,6 +142,7 @@ function AttachmentPreview({

function MessageComposer({
onSend,
onStop,
isLoading = false,
placeholder = 'Type a message...',
className,
Expand Down Expand Up @@ -372,9 +374,9 @@ function MessageComposer({

<button
type="button"
onClick={handleSend}
disabled={!canSend}
aria-label="Send message"
onClick={isLoading && onStop ? onStop : handleSend}
disabled={!canSend && !isLoading}
aria-label={isLoading ? 'Stop generating' : 'Send message'}
className={twMerge(
'absolute bottom-3 right-3',
'inline-flex items-center justify-center',
Expand All @@ -384,7 +386,7 @@ function MessageComposer({
canSend
? 'bg-accent text-white hover:bg-accent-hover active:scale-90 shadow-md shadow-accent/25'
: isLoading
? 'bg-text-muted/20 text-text-secondary hover:bg-text-muted/30'
? 'bg-text-muted/20 text-text-secondary hover:bg-text-muted/30 cursor-pointer'
: 'bg-transparent text-text-muted/40 cursor-default',
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ describe('MessageComposer', () => {
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled()
})

it('disables send button when isLoading is true', async () => {
const user = userEvent.setup()
it('shows stop button when isLoading is true', () => {
render(<MessageComposer onSend={vi.fn()} isLoading />)
// Textarea should be disabled too
// Textarea should be disabled
expect(screen.getByLabelText('Message input')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled()
// Button switches to "Stop generating" and is enabled
const stopBtn = screen.getByRole('button', { name: 'Stop generating' })
expect(stopBtn).not.toBeDisabled()
})

it('calls onStop when stop button is clicked', async () => {
const onStop = vi.fn()
const user = userEvent.setup()
render(<MessageComposer onSend={vi.fn()} onStop={onStop} isLoading />)
await user.click(screen.getByRole('button', { name: 'Stop generating' }))
expect(onStop).toHaveBeenCalledOnce()
})

it('calls onSend with trimmed content on button click', async () => {
Expand Down
53 changes: 44 additions & 9 deletions packages/agent/src/chat/MessageThread/MessageThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,54 @@ export type MessageThreadProps = {

function MessageThread({ messages, streamingSlot, showAgent, showSources, showConfidence, showVerification, hideLastAssistant, userBubbleClassName, className }: MessageThreadProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isNearBottom = useRef(true)
const isProgrammaticScroll = useRef(false)
const shouldAutoScroll = useRef(true)
const hasStreaming = !!streamingSlot

const scrollToBottom = useCallback(() => {
const el = scrollRef.current
if (el && isNearBottom.current) {
isProgrammaticScroll.current = true
if (el && shouldAutoScroll.current) {
el.scrollTop = el.scrollHeight
}
}, [])

const handleScroll = useCallback(() => {
if (isProgrammaticScroll.current) {
isProgrammaticScroll.current = false
return
// Detect user scroll-up intent via wheel events (never fired by programmatic scrolling)
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
shouldAutoScroll.current = false
}
}
const onPointerDown = () => {
// If the user interacts with the scroll area (e.g. scrollbar drag),
// let handleScroll determine whether to pause auto-scroll
// by marking this as a user-initiated interaction.
el.dataset.userPointer = '1'
}
const onPointerUp = () => {
delete el.dataset.userPointer
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointerup', onPointerUp)
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointerup', onPointerUp)
}
}, [])

const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isNearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80
if (nearBottom) {
shouldAutoScroll.current = true
} else if (el.dataset.userPointer) {
// User is dragging scrollbar upward
shouldAutoScroll.current = false
}
}, [])

// Scroll on new messages
Expand All @@ -56,6 +84,13 @@ function MessageThread({ messages, streamingSlot, showAgent, showSources, showCo
return () => cancelAnimationFrame(raf)
}, [hasStreaming, scrollToBottom])

// Reset auto-scroll when streaming ends so the next message auto-scrolls
useEffect(() => {
if (!hasStreaming) {
shouldAutoScroll.current = true
}
}, [hasStreaming])

return (
<div
ref={scrollRef}
Expand Down
Loading
Loading