Files
NomadArch/tasks/done/006-instance-session-tabs.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

13 KiB
Raw Permalink Blame History

Task 006: Instance & Session Tabs

Goal

Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations.

Prerequisites

  • Task 005 completed (Session picker modal, active session selection)
  • Understanding of tab navigation patterns
  • Familiarity with SolidJS For/Show components
  • Knowledge of keyboard accessibility

Acceptance Criteria

  • Instance tabs render at top level
  • Session tabs render below instance tabs for active instance
  • Can switch between instance tabs
  • Can switch between session tabs within an instance
  • Active tab is visually highlighted
  • Tab labels show appropriate text (folder name, session title)
  • Close buttons work on tabs (with confirmation)
  • "+" button creates new instance/session
  • Keyboard navigation works (Cmd/Ctrl+1-9 for tabs)
  • Tabs scroll horizontally when many exist
  • Properly styled and accessible

Steps

1. Create Instance Tabs Component

src/components/instance-tabs.tsx:

Props:

interface InstanceTabsProps {
  instances: Map<string, Instance>
  activeInstanceId: string | null
  onSelect: (instanceId: string) => void
  onClose: (instanceId: string) => void
  onNew: () => void
}

Structure:

<div class="instance-tabs">
  <div class="tabs-container">
    <For each={Array.from(instances.entries())}>
      {([id, instance]) => (
        <InstanceTab
          instance={instance}
          active={id === activeInstanceId}
          onSelect={() => onSelect(id)}
          onClose={() => onClose(id)}
        />
      )}
    </For>
    <button class="new-tab-button" onClick={onNew}>
      +
    </button>
  </div>
</div>

Styling:

  • Horizontal layout
  • Background: Secondary background color
  • Border bottom: 1px solid border color
  • Height: 40px
  • Padding: 0 8px
  • Overflow-x: auto (for many tabs)

2. Create Instance Tab Item Component

src/components/instance-tab.tsx:

Props:

interface InstanceTabProps {
  instance: Instance
  active: boolean
  onSelect: () => void
  onClose: () => void
}

Structure:

<button class={`instance-tab ${active ? "active" : ""}`} onClick={onSelect}>
  <span class="tab-icon">📁</span>
  <span class="tab-label">{formatFolderName(instance.folder)}</span>
  <button
    class="tab-close"
    onClick={(e) => {
      e.stopPropagation()
      onClose()
    }}
  >
    ×
  </button>
</button>

Styling:

  • Display: inline-flex
  • Align items center
  • Gap: 8px
  • Padding: 8px 12px
  • Border radius: 6px 6px 0 0
  • Max width: 200px
  • Truncate text with ellipsis
  • Active: Background accent color
  • Inactive: Transparent background
  • Hover: Light background

Folder Name Formatting:

function formatFolderName(path: string): string {
  const name = path.split("/").pop() || path
  return `~/${name}`
}

Handle Duplicates:

  • If multiple instances have same folder name, add counter
  • Example: ~/project, ~/project (2), ~/project (3)

3. Create Session Tabs Component

src/components/session-tabs.tsx:

Props:

interface SessionTabsProps {
  instanceId: string
  sessions: Map<string, Session>
  activeSessionId: string | null
  onSelect: (sessionId: string) => void
  onClose: (sessionId: string) => void
  onNew: () => void
}

Structure:

<div class="session-tabs">
  <div class="tabs-container">
    <For each={Array.from(sessions.entries())}>
      {([id, session]) => (
        <SessionTab
          session={session}
          active={id === activeSessionId}
          onSelect={() => onSelect(id)}
          onClose={() => onClose(id)}
        />
      )}
    </For>
    <SessionTab special="logs" active={activeSessionId === "logs"} onSelect={() => onSelect("logs")} />
    <button class="new-tab-button" onClick={onNew}>
      +
    </button>
  </div>
</div>

Styling:

  • Similar to instance tabs but smaller
  • Height: 36px
  • Font size: 13px
  • Less prominent than instance tabs

4. Create Session Tab Item Component

src/components/session-tab.tsx:

Props:

interface SessionTabProps {
  session?: Session
  special?: "logs"
  active: boolean
  onSelect: () => void
  onClose?: () => void
}

Structure:

<button class={`session-tab ${active ? "active" : ""} ${special ? "special" : ""}`} onClick={onSelect}>
  <span class="tab-label">{special === "logs" ? "Logs" : session?.title || "Untitled"}</span>
  <Show when={!special && onClose}>
    <button
      class="tab-close"
      onClick={(e) => {
        e.stopPropagation()
        onClose?.()
      }}
    >
      ×
    </button>
  </Show>
</button>

Styling:

  • Max width: 150px
  • Truncate with ellipsis
  • Active: Underline or bold text
  • Logs tab: Slightly different color/icon

5. Add Tab State Management

src/stores/ui.ts updates:

interface UIState {
  instanceTabOrder: string[]
  sessionTabOrder: Map<string, string[]>

  reorderInstanceTabs: (newOrder: string[]) => void
  reorderSessionTabs: (instanceId: string, newOrder: string[]) => void
}

const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())

function reorderInstanceTabs(newOrder: string[]) {
  setInstanceTabOrder(newOrder)
}

function reorderSessionTabs(instanceId: string, newOrder: string[]) {
  setSessionTabOrder((prev) => {
    const next = new Map(prev)
    next.set(instanceId, newOrder)
    return next
  })
}

6. Wire Up Tab Selection

src/stores/instances.ts updates:

function setActiveInstance(id: string) {
  activeInstanceId = id

  // Auto-select first session or show session picker
  const instance = instances.get(id)
  if (instance) {
    const sessions = Array.from(instance.sessions.values())
    if (sessions.length > 0 && !instance.activeSessionId) {
      instance.activeSessionId = sessions[0].id
    }
  }
}

function setActiveSession(instanceId: string, sessionId: string) {
  const instance = instances.get(instanceId)
  if (instance) {
    instance.activeSessionId = sessionId
  }
}

7. Handle Tab Close Actions

Close Instance Tab:

async function handleCloseInstance(instanceId: string) {
  const confirmed = await showConfirmDialog({
    title: "Stop OpenCode instance?",
    message: `This will stop the server for ${instance.folder}`,
    confirmText: "Stop Instance",
    destructive: true,
  })

  if (confirmed) {
    await removeInstance(instanceId)
  }
}

Close Session Tab:

async function handleCloseSession(instanceId: string, sessionId: string) {
  const session = getInstance(instanceId)?.sessions.get(sessionId)

  if (session && session.messages.length > 0) {
    const confirmed = await showConfirmDialog({
      title: "Delete session?",
      message: `This will permanently delete "${session.title}"`,
      confirmText: "Delete",
      destructive: true,
    })

    if (!confirmed) return
  }

  await deleteSession(instanceId, sessionId)

  // Switch to another session
  const instance = getInstance(instanceId)
  const remainingSessions = Array.from(instance.sessions.values())
  if (remainingSessions.length > 0) {
    setActiveSession(instanceId, remainingSessions[0].id)
  } else {
    // Show session picker
    showSessionPicker(instanceId)
  }
}

8. Handle New Tab Buttons

New Instance:

async function handleNewInstance() {
  const folder = await window.electronAPI.selectFolder()
  if (folder) {
    await createInstance(folder)
  }
}

New Session:

async function handleNewSession(instanceId: string) {
  // For now, use default agent
  // Later (Task 011) will show agent selector
  const session = await createSession(instanceId, "build")
  setActiveSession(instanceId, session.id)
}

9. Update App Layout

src/App.tsx:

<div class="app">
  <Show when={instances.size > 0} fallback={<EmptyState />}>
    <InstanceTabs
      instances={instances()}
      activeInstanceId={activeInstanceId()}
      onSelect={setActiveInstance}
      onClose={handleCloseInstance}
      onNew={handleNewInstance}
    />

    <Show when={activeInstance()}>
      {(instance) => (
        <>
          <SessionTabs
            instanceId={instance().id}
            sessions={instance().sessions}
            activeSessionId={instance().activeSessionId}
            onSelect={(id) => setActiveSession(instance().id, id)}
            onClose={(id) => handleCloseSession(instance().id, id)}
            onNew={() => handleNewSession(instance().id)}
          />

          <div class="content-area">
            {/* Message stream and input will go here in Task 007 */}
            <Show when={instance().activeSessionId === "logs"}>
              <LogsView logs={instance().logs} />
            </Show>
            <Show when={instance().activeSessionId !== "logs"}>
              <div class="placeholder">Session content will appear here (Task 007)</div>
            </Show>
          </div>
        </>
      )}
    </Show>
  </Show>
</div>

10. Add Keyboard Shortcuts

Keyboard navigation:

// src/lib/keyboard.ts

export function setupTabKeyboardShortcuts() {
  window.addEventListener("keydown", (e) => {
    // Cmd/Ctrl + 1-9: Switch instance tabs
    if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
      e.preventDefault()
      const index = parseInt(e.key) - 1
      const instances = Array.from(instanceStore.instances.keys())
      if (instances[index]) {
        setActiveInstance(instances[index])
      }
    }

    // Cmd/Ctrl + N: New instance
    if ((e.metaKey || e.ctrlKey) && e.key === "n") {
      e.preventDefault()
      handleNewInstance()
    }

    // Cmd/Ctrl + T: New session
    if ((e.metaKey || e.ctrlKey) && e.key === "t") {
      e.preventDefault()
      if (activeInstanceId()) {
        handleNewSession(activeInstanceId()!)
      }
    }

    // Cmd/Ctrl + W: Close current tab
    if ((e.metaKey || e.ctrlKey) && e.key === "w") {
      e.preventDefault()
      const instanceId = activeInstanceId()
      const instance = getInstance(instanceId)
      if (instance?.activeSessionId && instance.activeSessionId !== "logs") {
        handleCloseSession(instanceId!, instance.activeSessionId)
      }
    }
  })
}

Call in main.tsx:

import { setupTabKeyboardShortcuts } from "./lib/keyboard"

onMount(() => {
  setupTabKeyboardShortcuts()
})

11. Add Accessibility

ARIA attributes:

<div role="tablist" aria-label="Instance tabs">
  <button
    role="tab"
    aria-selected={active}
    aria-controls={`instance-panel-${instance.id}`}
  >
    ...
  </button>
</div>

<div
  role="tabpanel"
  id={`instance-panel-${instance.id}`}
  aria-labelledby={`instance-tab-${instance.id}`}
>
  {/* Session tabs */}
</div>

Focus management:

  • Tab key cycles through tabs
  • Arrow keys navigate within tab list
  • Focus indicators visible
  • Skip links for screen readers

12. Style Refinements

Horizontal scroll:

.tabs-container {
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: thin;
}

.tabs-container::-webkit-scrollbar {
  height: 4px;
}

.tabs-container::-webkit-scrollbar-thumb {
  background: var(--border-color);
  border-radius: 2px;
}

Tab animations:

.instance-tab,
.session-tab {
  transition: background-color 150ms ease;
}

.instance-tab:hover,
.session-tab:hover {
  background-color: var(--hover-background);
}

.instance-tab.active,
.session-tab.active {
  background-color: var(--active-background);
}

Close button styling:

.tab-close {
  opacity: 0;
  transition: opacity 150ms ease;
}

.instance-tab:hover .tab-close,
.session-tab:hover .tab-close {
  opacity: 1;
}

.tab-close:hover {
  background-color: var(--danger-background);
  color: var(--danger-color);
}

Testing Checklist

Manual Tests:

  1. Create instance → Instance tab appears
  2. Click instance tab → Switches active instance
  3. Session tabs appear below active instance
  4. Click session tab → Switches active session
  5. Click "+" on instance tabs → Opens folder picker
  6. Click "+" on session tabs → Creates new session
  7. Click close on instance tab → Shows confirmation, closes
  8. Click close on session tab → Closes session
  9. Cmd/Ctrl+1 switches to first instance
  10. Cmd/Ctrl+N opens new instance
  11. Cmd/Ctrl+T creates new session
  12. Cmd/Ctrl+W closes active session
  13. Tabs scroll when many exist
  14. Logs tab always visible and non-closable
  15. Tab labels truncate long names

Edge Cases:

  • Only one instance (no scrolling needed)
  • Many instances (>10, horizontal scroll)
  • No sessions in instance (only Logs tab visible)
  • Duplicate folder names (counter added)
  • Very long folder/session names (ellipsis)
  • Close last session (session picker appears)
  • Switch instance while session is streaming

Dependencies

  • Blocks: Task 007 (needs tab structure to display messages)
  • Blocked by: Task 005 (needs session selection to work)

Estimated Time

4-5 hours

Notes

  • Keep tab design clean and minimal
  • Don't over-engineer tab reordering (can add later)
  • Focus on functionality over fancy animations
  • Ensure keyboard accessibility from the start
  • Tab state will persist in Task 017
  • Context menus for tabs can be added in Task 026