Files
NomadArch/tasks/done/007-message-display.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb):
- .github/ workflows and issue templates
- .gitignore (proper exclusions)
- .opencode/agent/web_developer.md
- AGENTS.md, BUILD.md, PROGRESS.md
- dev-docs/ (9 architecture/implementation docs)
- docs/screenshots/ (4 UI screenshots)
- images/ (CodeNomad icons)
- package-lock.json (dependency lockfile)
- tasks/ (25+ project task files)

Also restored original source files that were modified:
- packages/ui/src/App.tsx
- packages/ui/src/lib/logger.ts
- packages/ui/src/stores/instances.ts
- packages/server/src/server/routes/workspaces.ts
- packages/server/src/workspaces/manager.ts
- packages/server/src/workspaces/runtime.ts
- packages/server/package.json

Kept new additions:
- Install-*.bat/.sh (enhanced installers)
- Launch-*.bat/.sh (new launchers)
- README.md (SEO optimized with GLM 4.7)
2025-12-23 13:03:48 +04:00

17 KiB

Task 007: Message Display

Goal

Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states.

Note: This legacy task predates message-stream-v2 and the normalized message store; the new implementation lives under packages/ui/src/components/message-stream-v2.tsx.

Prerequisites

  • Task 006 completed (Tab navigation in place)
  • Understanding of message part structure from OpenCode SDK
  • Familiarity with markdown rendering
  • Knowledge of SolidJS For/Show components

Acceptance Criteria

  • Messages render in chronological order
  • User messages display with correct styling
  • Assistant messages display with agent label
  • Text content renders properly
  • Tool calls display inline with collapse/expand
  • Auto-scroll to bottom on new messages
  • Manual scroll up disables auto-scroll
  • "Scroll to bottom" button appears when scrolled up
  • Empty state shows when no messages
  • Loading state shows when fetching messages
  • Timestamps display for each message
  • Messages are accessible and keyboard-navigable

Steps

1. Define Message Types

src/types/message.ts:

export interface Message {
  id: string
  sessionId: string
  type: "user" | "assistant"
  parts: MessagePart[]
  timestamp: number
  status: "sending" | "sent" | "streaming" | "complete" | "error"
}

export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart

export interface TextPart {
  type: "text"
  text: string
}

export interface ToolCallPart {
  type: "tool_call"
  id: string
  tool: string
  input: any
  status: "pending" | "running" | "success" | "error"
}

export interface ToolResultPart {
  type: "tool_result"
  toolCallId: string
  output: any
  error?: string
}

export interface ErrorPart {
  type: "error"
  message: string
}

2. Create Message Stream Component

src/components/message-stream.tsx:

import { For, Show, createSignal, onMount, onCleanup } from "solid-js"
import { Message } from "../types/message"
import MessageItem from "./message-item"

interface MessageStreamProps {
  sessionId: string
  messages: Message[]
  loading?: boolean
}

export default function MessageStream(props: MessageStreamProps) {
  let containerRef: HTMLDivElement | undefined
  const [autoScroll, setAutoScroll] = createSignal(true)
  const [showScrollButton, setShowScrollButton] = createSignal(false)

  function scrollToBottom() {
    if (containerRef) {
      containerRef.scrollTop = containerRef.scrollHeight
      setAutoScroll(true)
      setShowScrollButton(false)
    }
  }

  function handleScroll() {
    if (!containerRef) return

    const { scrollTop, scrollHeight, clientHeight } = containerRef
    const isAtBottom = scrollHeight - scrollTop - clientHeight < 50

    setAutoScroll(isAtBottom)
    setShowScrollButton(!isAtBottom)
  }

  onMount(() => {
    if (autoScroll()) {
      scrollToBottom()
    }
  })

  // Auto-scroll when new messages arrive
  const messagesLength = () => props.messages.length
  createEffect(() => {
    messagesLength() // Track changes
    if (autoScroll()) {
      setTimeout(scrollToBottom, 0)
    }
  })

  return (
    <div class="message-stream-container">
      <div
        ref={containerRef}
        class="message-stream"
        onScroll={handleScroll}
      >
        <Show when={!props.loading && props.messages.length === 0}>
          <div class="empty-state">
            <div class="empty-state-content">
              <h3>Start a conversation</h3>
              <p>Type a message below or try:</p>
              <ul>
                <li><code>/init-project</code></li>
                <li>Ask about your codebase</li>
                <li>Attach files with <code>@</code></li>
              </ul>
            </div>
          </div>
        </Show>

        <Show when={props.loading}>
          <div class="loading-state">
            <div class="spinner" />
            <p>Loading messages...</p>
          </div>
        </Show>

        <For each={props.messages}>
          {(message) => (
            <MessageItem message={message} />
          )}
        </For>
      </div>

      <Show when={showScrollButton()}>
        <button
          class="scroll-to-bottom"
          onClick={scrollToBottom}
          aria-label="Scroll to bottom"
        >
          
        </button>
      </Show>
    </div>
  )
}

3. Create Message Item Component

src/components/message-item.tsx:

import { For, Show } from "solid-js"
import { Message } from "../types/message"
import MessagePart from "./message-part"

interface MessageItemProps {
  message: Message
}

export default function MessageItem(props: MessageItemProps) {
  const isUser = () => props.message.type === "user"
  const timestamp = () => {
    const date = new Date(props.message.timestamp)
    return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
  }

  return (
    <div class={`message-item ${isUser() ? "user" : "assistant"}`}>
      <div class="message-header">
        <span class="message-sender">
          {isUser() ? "You" : "Assistant"}
        </span>
        <span class="message-timestamp">{timestamp()}</span>
      </div>

      <div class="message-content">
        <For each={props.message.parts}>
          {(part) => <MessagePart part={part} />}
        </For>
      </div>

      <Show when={props.message.status === "error"}>
        <div class="message-error">
           Message failed to send
        </div>
      </Show>
    </div>
  )
}

4. Create Message Part Component

src/components/message-part.tsx:

import { Show, Match, Switch } from "solid-js"
import { MessagePart as MessagePartType } from "../types/message"
import ToolCall from "./tool-call"

interface MessagePartProps {
  part: MessagePartType
}

export default function MessagePart(props: MessagePartProps) {
  return (
    <Switch>
      <Match when={props.part.type === "text"}>
        <div class="message-text">
          {(props.part as any).text}
        </div>
      </Match>

      <Match when={props.part.type === "tool_call"}>
        <ToolCall toolCall={props.part as any} />
      </Match>

      <Match when={props.part.type === "error"}>
        <div class="message-error-part">
           {(props.part as any).message}
        </div>
      </Match>
    </Switch>
  )
}

5. Create Tool Call Component

src/components/tool-call.tsx:

import { createSignal, Show } from "solid-js"
import { ToolCallPart } from "../types/message"

interface ToolCallProps {
  toolCall: ToolCallPart
}

export default function ToolCall(props: ToolCallProps) {
  const [expanded, setExpanded] = createSignal(false)

  const statusIcon = () => {
    switch (props.toolCall.status) {
      case "pending":
        return "⏳"
      case "running":
        return "⏳"
      case "success":
        return "✓"
      case "error":
        return "✗"
      default:
        return ""
    }
  }

  const statusClass = () => {
    return `tool-call-status-${props.toolCall.status}`
  }

  function toggleExpanded() {
    setExpanded(!expanded())
  }

  function formatToolSummary() {
    // Create a brief summary of the tool call
    const { tool, input } = props.toolCall

    switch (tool) {
      case "bash":
        return `bash: ${input.command}`
      case "edit":
        return `edit ${input.filePath}`
      case "read":
        return `read ${input.filePath}`
      case "write":
        return `write ${input.filePath}`
      default:
        return `${tool}`
    }
  }

  return (
    <div class={`tool-call ${statusClass()}`}>
      <button
        class="tool-call-header"
        onClick={toggleExpanded}
        aria-expanded={expanded()}
      >
        <span class="tool-call-icon">
          {expanded() ? "▼" : "▶"}
        </span>
        <span class="tool-call-summary">
          {formatToolSummary()}
        </span>
        <span class="tool-call-status">
          {statusIcon()}
        </span>
      </button>

      <Show when={expanded()}>
        <div class="tool-call-details">
          <div class="tool-call-section">
            <h4>Input:</h4>
            <pre><code>{JSON.stringify(props.toolCall.input, null, 2)}</code></pre>
          </div>

          <Show when={props.toolCall.status === "success" || props.toolCall.status === "error"}>
            <div class="tool-call-section">
              <h4>Output:</h4>
              <pre><code>{formatToolOutput()}</code></pre>
            </div>
          </Show>
        </div>
      </Show>
    </div>
  )

  function formatToolOutput() {
    // This will be enhanced in later tasks
    // For now, just stringify
    return "Output will be displayed here"
  }
}

6. Add Message Store Integration

src/stores/sessions.ts updates:

interface Session {
  // ... existing fields
  messages: Message[]
}

async function loadMessages(instanceId: string, sessionId: string) {
  const instance = getInstance(instanceId)
  if (!instance) return

  try {
    // Fetch messages from SDK
    const response = await instance.client.session.getMessages(sessionId)

    // Update session with messages
    const session = instance.sessions.get(sessionId)
    if (session) {
      session.messages = response.messages.map(transformMessage)
    }
  } catch (error) {
    console.error("Failed to load messages:", error)
    throw error
  }
}

function transformMessage(apiMessage: any): Message {
  return {
    id: apiMessage.id,
    sessionId: apiMessage.sessionId,
    type: apiMessage.type,
    parts: apiMessage.parts || [],
    timestamp: apiMessage.timestamp || Date.now(),
    status: "complete",
  }
}

7. Update App to Show Messages

src/App.tsx updates:

<Show when={instance().activeSessionId !== "logs"}>
  {() => {
    const session = instance().sessions.get(instance().activeSessionId!)

    return (
      <Show when={session} fallback={<div>Session not found</div>}>
        {(s) => <MessageStream sessionId={s().id} messages={s().messages} loading={false} />}
      </Show>
    )
  }}
</Show>

8. Add Styling

src/components/message-stream.css:

.message-stream-container {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.message-stream {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.message-item {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 16px;
  border-radius: 8px;
  max-width: 85%;
}

.message-item.user {
  align-self: flex-end;
  background-color: var(--user-message-bg);
}

.message-item.assistant {
  align-self: flex-start;
  background-color: var(--assistant-message-bg);
}

.message-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.message-sender {
  font-weight: 600;
  font-size: 14px;
}

.message-timestamp {
  font-size: 12px;
  color: var(--text-muted);
}

.message-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message-text {
  font-size: 14px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.tool-call {
  margin: 8px 0;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  overflow: hidden;
}

.tool-call-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  width: 100%;
  background-color: var(--secondary-bg);
  border: none;
  cursor: pointer;
  font-family: monospace;
  font-size: 13px;
}

.tool-call-header:hover {
  background-color: var(--hover-bg);
}

.tool-call-icon {
  font-size: 10px;
}

.tool-call-summary {
  flex: 1;
  text-align: left;
}

.tool-call-status {
  font-size: 14px;
}

.tool-call-status-success {
  border-left: 3px solid var(--success-color);
}

.tool-call-status-error {
  border-left: 3px solid var(--error-color);
}

.tool-call-status-running {
  border-left: 3px solid var(--warning-color);
}

.tool-call-details {
  padding: 12px;
  background-color: var(--code-bg);
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.tool-call-section h4 {
  font-size: 12px;
  font-weight: 600;
  margin-bottom: 4px;
  color: var(--text-muted);
}

.tool-call-section pre {
  margin: 0;
  padding: 8px;
  background-color: var(--background);
  border-radius: 4px;
  overflow-x: auto;
}

.tool-call-section code {
  font-family: monospace;
  font-size: 12px;
  line-height: 1.4;
}

.scroll-to-bottom {
  position: absolute;
  bottom: 16px;
  right: 16px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: var(--accent-color);
  color: white;
  border: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  cursor: pointer;
  font-size: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 150ms ease;
}

.scroll-to-bottom:hover {
  transform: scale(1.1);
}

.empty-state {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 48px;
}

.empty-state-content {
  text-align: center;
  max-width: 400px;
}

.empty-state-content h3 {
  font-size: 18px;
  margin-bottom: 12px;
}

.empty-state-content p {
  font-size: 14px;
  color: var(--text-muted);
  margin-bottom: 16px;
}

.empty-state-content ul {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.empty-state-content li {
  font-size: 14px;
  color: var(--text-muted);
}

.empty-state-content code {
  background-color: var(--code-bg);
  padding: 2px 6px;
  border-radius: 3px;
  font-family: monospace;
  font-size: 13px;
}

.loading-state {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 48px;
}

.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid var(--border-color);
  border-top-color: var(--accent-color);
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

9. Add CSS Variables

src/index.css updates:

:root {
  /* Message colors */
  --user-message-bg: #e3f2fd;
  --assistant-message-bg: #f5f5f5;

  /* Status colors */
  --success-color: #4caf50;
  --error-color: #f44336;
  --warning-color: #ff9800;

  /* Code colors */
  --code-bg: #f8f8f8;
}

[data-theme="dark"] {
  --user-message-bg: #1e3a5f;
  --assistant-message-bg: #2a2a2a;
  --code-bg: #1a1a1a;
}

10. Load Messages on Session Switch

src/hooks/use-session.ts:

import { createEffect } from "solid-js"

export function useSession(instanceId: string, sessionId: string) {
  createEffect(() => {
    // Load messages when session becomes active
    if (sessionId && sessionId !== "logs") {
      loadMessages(instanceId, sessionId).catch(console.error)
    }
  })
}

Use in App.tsx:

<Show when={session}>
  {(s) => {
    useSession(instance().id, s().id)

    return <MessageStream sessionId={s().id} messages={s().messages} loading={false} />
  }}
</Show>

11. Add Accessibility

ARIA attributes:

<div
  class="message-stream"
  role="log"
  aria-live="polite"
  aria-atomic="false"
  aria-label="Message history"
>
  {/* Messages */}
</div>

<div
  class="message-item"
  role="article"
  aria-label={`${isUser() ? "Your" : "Assistant"} message at ${timestamp()}`}
>
  {/* Message content */}
</div>

Keyboard navigation:

  • Messages should be accessible via Tab key
  • Tool calls can be expanded with Enter/Space
  • Screen readers announce new messages

12. Handle Long Messages

Text wrapping:

.message-text {
  overflow-wrap: break-word;
  word-wrap: break-word;
  hyphens: auto;
}

Code blocks (for now, just basic):

.message-text pre {
  overflow-x: auto;
  padding: 8px;
  background-color: var(--code-bg);
  border-radius: 4px;
}

Testing Checklist

Manual Tests:

  1. Empty session shows empty state
  2. Messages load when switching sessions
  3. User messages appear on right
  4. Assistant messages appear on left
  5. Timestamps display correctly
  6. Tool calls appear inline
  7. Tool calls expand/collapse on click
  8. Auto-scroll works for new messages
  9. Manual scroll up disables auto-scroll
  10. Scroll to bottom button appears/works
  11. Long messages wrap correctly
  12. Multiple messages display properly
  13. Messages are keyboard accessible

Edge Cases:

  • Session with 1 message
  • Session with 100+ messages
  • Messages with very long text
  • Messages with no parts
  • Tool calls with large output
  • Rapid message updates
  • Switching sessions while loading

Dependencies

  • Blocks: Task 008 (SSE will update these messages in real-time)
  • Blocked by: Task 006 (needs tab structure)

Estimated Time

4-5 hours

Notes

  • Keep styling simple for now - markdown rendering comes in Task 012
  • Tool output formatting will be enhanced in Task 010
  • Focus on basic text display and structure
  • Don't optimize for virtual scrolling yet (MVP principle)
  • Message actions (copy, edit, etc.) come in Task 026
  • This is the foundation for real-time updates in Task 008