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)
This commit is contained in:
591
tasks/done/006-instance-session-tabs.md
Normal file
591
tasks/done/006-instance-session-tabs.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 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:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<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:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<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:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabsProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<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:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabProps {
|
||||
session?: Session
|
||||
special?: "logs"
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<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:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
async function handleNewInstance() {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
await createInstance(folder)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Session:**
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```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:**
|
||||
|
||||
```typescript
|
||||
// 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:**
|
||||
|
||||
```typescript
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
|
||||
onMount(() => {
|
||||
setupTabKeyboardShortcuts()
|
||||
})
|
||||
```
|
||||
|
||||
### 11. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
```tsx
|
||||
<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:**
|
||||
|
||||
```css
|
||||
.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:**
|
||||
|
||||
```css
|
||||
.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:**
|
||||
|
||||
```css
|
||||
.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
|
||||
Reference in New Issue
Block a user