@andypai/agent-kanban 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
@@ -0,0 +1,135 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { listActivity } from '../activity.ts'
3
+ import { getConfigPath, loadConfig, saveConfig } from '../config.ts'
4
+ import {
5
+ addTask,
6
+ deleteTask,
7
+ getBoardView,
8
+ getDbPath,
9
+ getTask,
10
+ listColumns,
11
+ listTasks,
12
+ moveTask,
13
+ updateTask,
14
+ } from '../db.ts'
15
+ import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics.ts'
16
+ import type { BoardBootstrap, BoardConfig, Task } from '../types.ts'
17
+ import { LOCAL_CAPABILITIES } from './capabilities.ts'
18
+ import type {
19
+ CreateTaskInput,
20
+ KanbanProvider,
21
+ ProviderContext,
22
+ TaskListFilters,
23
+ UpdateTaskInput,
24
+ } from './types.ts'
25
+
26
+ function buildLocalConfig(
27
+ db: Database,
28
+ dbPath: string,
29
+ discoveredAssignees = getDiscoveredAssignees(db),
30
+ discoveredProjects = getDiscoveredProjects(db),
31
+ ): BoardConfig {
32
+ return {
33
+ ...loadConfig(dbPath),
34
+ provider: 'local',
35
+ discoveredAssignees,
36
+ discoveredProjects,
37
+ }
38
+ }
39
+
40
+ function enrichTask(task: Task): Task {
41
+ return {
42
+ ...task,
43
+ providerId: task.id,
44
+ externalRef: task.id,
45
+ url: null,
46
+ }
47
+ }
48
+
49
+ export class LocalProvider implements KanbanProvider {
50
+ readonly type = 'local' as const
51
+
52
+ constructor(
53
+ private readonly db: Database,
54
+ private readonly dbPath = getDbPath(),
55
+ ) {}
56
+
57
+ async getContext(): Promise<ProviderContext> {
58
+ return {
59
+ provider: this.type,
60
+ capabilities: LOCAL_CAPABILITIES,
61
+ team: null,
62
+ }
63
+ }
64
+
65
+ async getBootstrap(): Promise<BoardBootstrap> {
66
+ const metrics = getBoardMetrics(this.db)
67
+ return {
68
+ provider: this.type,
69
+ capabilities: LOCAL_CAPABILITIES,
70
+ board: await this.getBoard(),
71
+ config: buildLocalConfig(this.db, this.dbPath, metrics.assignees, metrics.projects),
72
+ metrics,
73
+ activity: listActivity(this.db, { limit: 50 }),
74
+ team: null,
75
+ }
76
+ }
77
+
78
+ async getBoard() {
79
+ const board = getBoardView(this.db)
80
+ return {
81
+ columns: board.columns.map((column) => ({
82
+ ...column,
83
+ tasks: column.tasks.map(enrichTask),
84
+ })),
85
+ }
86
+ }
87
+
88
+ async listColumns() {
89
+ return listColumns(this.db)
90
+ }
91
+
92
+ async listTasks(filters: TaskListFilters = {}) {
93
+ return listTasks(this.db, filters).map(enrichTask)
94
+ }
95
+
96
+ async getTask(idOrRef: string) {
97
+ return enrichTask(getTask(this.db, idOrRef))
98
+ }
99
+
100
+ async createTask(input: CreateTaskInput) {
101
+ return enrichTask(addTask(this.db, input.title, input))
102
+ }
103
+
104
+ async updateTask(idOrRef: string, input: UpdateTaskInput) {
105
+ return enrichTask(updateTask(this.db, idOrRef, input))
106
+ }
107
+
108
+ async moveTask(idOrRef: string, column: string) {
109
+ return enrichTask(moveTask(this.db, idOrRef, column))
110
+ }
111
+
112
+ async deleteTask(idOrRef: string) {
113
+ return enrichTask(deleteTask(this.db, idOrRef))
114
+ }
115
+
116
+ async getActivity(limit?: number, taskId?: string) {
117
+ return listActivity(this.db, { limit, taskId })
118
+ }
119
+
120
+ async getMetrics() {
121
+ return getBoardMetrics(this.db)
122
+ }
123
+
124
+ async getConfig(): Promise<BoardConfig> {
125
+ return buildLocalConfig(this.db, this.dbPath)
126
+ }
127
+
128
+ async patchConfig(input: Partial<BoardConfig>) {
129
+ const config = loadConfig(this.dbPath)
130
+ if (input.members) config.members = input.members
131
+ if (input.projects) config.projects = input.projects
132
+ saveConfig(getConfigPath(this.dbPath), config)
133
+ return this.getConfig()
134
+ }
135
+ }
@@ -0,0 +1,65 @@
1
+ import type {
2
+ ActivityEntry,
3
+ BoardBootstrap,
4
+ BoardConfig,
5
+ BoardMetrics,
6
+ BoardView,
7
+ Column,
8
+ Priority,
9
+ ProviderCapabilities,
10
+ ProviderTeamInfo,
11
+ Task,
12
+ } from '../types.ts'
13
+
14
+ export interface TaskListFilters {
15
+ column?: string
16
+ priority?: string
17
+ assignee?: string
18
+ project?: string
19
+ limit?: number
20
+ sort?: string
21
+ }
22
+
23
+ export interface CreateTaskInput {
24
+ title: string
25
+ description?: string
26
+ column?: string
27
+ priority?: Priority
28
+ assignee?: string
29
+ project?: string
30
+ metadata?: string
31
+ }
32
+
33
+ export interface UpdateTaskInput {
34
+ title?: string
35
+ description?: string
36
+ priority?: Priority
37
+ assignee?: string
38
+ project?: string
39
+ metadata?: string
40
+ }
41
+
42
+ export interface ProviderContext {
43
+ provider: 'local' | 'linear'
44
+ capabilities: ProviderCapabilities
45
+ team: ProviderTeamInfo | null
46
+ }
47
+
48
+ export interface KanbanProvider {
49
+ readonly type: 'local' | 'linear'
50
+
51
+ getContext(): Promise<ProviderContext>
52
+ getBootstrap(): Promise<BoardBootstrap>
53
+ getBoard(): Promise<BoardView>
54
+ listColumns(): Promise<Column[]>
55
+ listTasks(filters?: TaskListFilters): Promise<Task[]>
56
+ getTask(idOrRef: string): Promise<Task>
57
+ createTask(input: CreateTaskInput): Promise<Task>
58
+ updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
59
+ moveTask(idOrRef: string, column: string): Promise<Task>
60
+ deleteTask(idOrRef: string): Promise<Task>
61
+ getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
62
+ getMetrics(): Promise<BoardMetrics>
63
+ getConfig(): Promise<BoardConfig>
64
+ patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
65
+ }
package/src/server.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { handleRequest } from './api.ts'
4
+ import type { ServerWebSocket } from 'bun'
5
+ import type { KanbanProvider } from './providers/types.ts'
6
+
7
+ const wsClients = new Set<ServerWebSocket<unknown>>()
8
+ const CORS_HEADERS = {
9
+ 'Access-Control-Allow-Origin': '*',
10
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
11
+ 'Access-Control-Allow-Headers': 'Content-Type',
12
+ }
13
+
14
+ function broadcast(data: unknown): void {
15
+ const msg = JSON.stringify(data)
16
+ for (const ws of wsClients) {
17
+ ws.send(msg)
18
+ }
19
+ }
20
+
21
+ function applyCorsHeaders(response: Response): void {
22
+ for (const [header, value] of Object.entries(CORS_HEADERS)) {
23
+ response.headers.set(header, value)
24
+ }
25
+ }
26
+
27
+ export function startServer(provider: KanbanProvider, port: number): void {
28
+ const distDir = join(import.meta.dir, '..', 'ui', 'dist')
29
+ const hasStatic = existsSync(distDir)
30
+
31
+ Bun.serve({
32
+ port,
33
+ websocket: {
34
+ open(ws) {
35
+ wsClients.add(ws)
36
+ },
37
+ close(ws) {
38
+ wsClients.delete(ws)
39
+ },
40
+ message() {
41
+ /* server-push only */
42
+ },
43
+ },
44
+ async fetch(req, server) {
45
+ const url = new URL(req.url)
46
+
47
+ // Handle OPTIONS preflight first (before /api routing)
48
+ if (req.method === 'OPTIONS') {
49
+ return new Response(null, { headers: CORS_HEADERS })
50
+ }
51
+
52
+ // WebSocket upgrade
53
+ if (url.pathname === '/ws') {
54
+ const upgraded = server.upgrade(req)
55
+ if (upgraded) return undefined as unknown as Response
56
+ return new Response('WebSocket upgrade failed', { status: 400 })
57
+ }
58
+
59
+ if (url.pathname === '/api/health') {
60
+ const context = await provider.getContext()
61
+ return Response.json({
62
+ ok: true,
63
+ data: { status: 'running', wsClients: wsClients.size, provider: context.provider },
64
+ })
65
+ }
66
+
67
+ if (url.pathname.startsWith('/api/')) {
68
+ const result = await handleRequest(provider, req)
69
+ applyCorsHeaders(result.response)
70
+ if (result.mutated && result.response.ok) {
71
+ broadcast({ type: 'refresh' })
72
+ }
73
+ return result.response
74
+ }
75
+
76
+ if (hasStatic) {
77
+ const filePath = join(distDir, url.pathname === '/' ? 'index.html' : url.pathname)
78
+ const file = Bun.file(filePath)
79
+ if (await file.exists()) return new Response(file)
80
+ return new Response(Bun.file(join(distDir, 'index.html')))
81
+ }
82
+
83
+ return new Response('Dashboard not built. Run: cd ui && bun run build', {
84
+ status: 503,
85
+ headers: { 'Content-Type': 'text/plain' },
86
+ })
87
+ },
88
+ })
89
+
90
+ console.info(`Dashboard running at http://localhost:${port}`)
91
+ }
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ export type Priority = 'low' | 'medium' | 'high' | 'urgent'
2
+
3
+ export interface Column {
4
+ id: string
5
+ name: string
6
+ position: number
7
+ color: string | null
8
+ created_at: string
9
+ updated_at: string
10
+ }
11
+
12
+ export interface Task {
13
+ id: string
14
+ providerId?: string
15
+ externalRef?: string | null
16
+ url?: string | null
17
+ title: string
18
+ description: string
19
+ column_id: string
20
+ position: number
21
+ priority: Priority
22
+ assignee: string
23
+ project: string
24
+ metadata: string
25
+ created_at: string
26
+ updated_at: string
27
+ }
28
+
29
+ export interface TaskWithColumn extends Task {
30
+ column_name: string
31
+ }
32
+
33
+ export interface BoardView {
34
+ columns: (Column & { tasks: Task[] })[]
35
+ }
36
+
37
+ export interface CliResult<T = unknown> {
38
+ ok: true
39
+ data: T
40
+ }
41
+
42
+ export interface CliError {
43
+ ok: false
44
+ error: { code: string; message: string }
45
+ }
46
+
47
+ export type CliOutput<T = unknown> = CliResult<T> | CliError
48
+
49
+ export type ActivityAction =
50
+ | 'created'
51
+ | 'moved'
52
+ | 'updated'
53
+ | 'deleted'
54
+ | 'assigned'
55
+ | 'prioritized'
56
+
57
+ export interface ActivityEntry {
58
+ id: string
59
+ task_id: string
60
+ action: ActivityAction
61
+ field_changed: string | null
62
+ old_value: string | null
63
+ new_value: string | null
64
+ timestamp: string
65
+ }
66
+
67
+ export interface ColumnTimeEntry {
68
+ id: string
69
+ task_id: string
70
+ column_id: string
71
+ entered_at: string
72
+ exited_at: string | null
73
+ }
74
+
75
+ export interface BoardConfig {
76
+ members: { name: string; role: 'human' | 'agent' }[]
77
+ projects: string[]
78
+ provider?: 'local' | 'linear'
79
+ discoveredAssignees?: string[]
80
+ discoveredProjects?: string[]
81
+ }
82
+
83
+ export interface BoardMetrics {
84
+ tasksByColumn: { column_name: string; count: number }[]
85
+ tasksByPriority: { priority: string; count: number }[]
86
+ totalTasks: number
87
+ completedTasks: number
88
+ avgCompletionHours: number | null
89
+ recentActivity: ActivityEntry[]
90
+ tasksCreatedThisWeek: number
91
+ inProgressCount: number
92
+ completionPercent: number
93
+ assignees: string[]
94
+ projects: string[]
95
+ }
96
+
97
+ export interface ProviderCapabilities {
98
+ taskCreate: boolean
99
+ taskUpdate: boolean
100
+ taskMove: boolean
101
+ taskDelete: boolean
102
+ activity: boolean
103
+ metrics: boolean
104
+ columnCrud: boolean
105
+ bulk: boolean
106
+ configEdit: boolean
107
+ }
108
+
109
+ export interface ProviderTeamInfo {
110
+ id: string
111
+ key: string
112
+ name: string
113
+ }
114
+
115
+ export interface BoardBootstrap {
116
+ provider: 'local' | 'linear'
117
+ capabilities: ProviderCapabilities
118
+ board: BoardView
119
+ config: BoardConfig
120
+ metrics: BoardMetrics | null
121
+ activity: ActivityEntry[]
122
+ team: ProviderTeamInfo | null
123
+ }
@@ -0,0 +1 @@
1
+ *,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-primary: #0f1117;--bg-secondary: #1a1d27;--bg-card: #1e2130;--bg-hover: #252838;--border: #2a2d3a;--border-light: #353849;--text-primary: rgba(255, 255, 255, .92);--text-secondary: #9ca3af;--text-muted: #6b7280;--accent: #6366f1;--accent-hover: #818cf8;--priority-urgent: #ef4444;--priority-high: #f97316;--priority-medium: #eab308;--priority-low: #22c55e;--col-recurring: #6b7280;--col-backlog: #f59e0b;--col-in-progress: #3b82f6;--col-review: #8b5cf6;--col-done: #22c55e;--section-radius: 10px}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);min-height:100vh}#root{width:100%;max-width:1800px;margin:0 auto;padding:16px 24px}.header{padding:12px 0 16px;border-bottom:1px solid var(--border);margin-bottom:16px}.headerTop{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.header h1{font-size:20px;font-weight:700;letter-spacing:-.02em}.header h1 span{color:var(--accent)}.newTaskBtn{background:var(--accent);color:#fff;border:none;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}.newTaskBtn:hover{background:var(--accent-hover)}.statsBar{display:flex;gap:12px;margin-bottom:14px}.statCard{background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 16px;display:flex;align-items:baseline;gap:8px}.statValue{font-size:20px;font-weight:700;color:var(--accent)}.statLabel{font-size:12px;color:var(--text-muted)}.filterBar{display:flex;align-items:center;justify-content:space-between;gap:12px}.filterGroup{display:flex;gap:6px;align-items:center}.filterBtn{background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-secondary);padding:5px 14px;border-radius:20px;font-size:12px;cursor:pointer;transition:all .15s}.filterBtn:hover{border-color:var(--border-light);color:var(--text-primary)}.filterBtn.active{background:var(--accent);border-color:var(--accent);color:#fff}.projectDropdown{background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-secondary);padding:5px 28px 5px 14px;border-radius:20px;font-size:12px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.projectDropdown:focus{outline:none;border-color:var(--accent)}.board{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;min-height:calc(100vh - 220px)}.column{flex:1;min-width:240px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--section-radius);display:flex;flex-direction:column}.columnHeader{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}.columnDot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.columnName{font-size:13px;font-weight:600;text-transform:capitalize;flex:1}.columnCount{background:var(--border);color:var(--text-secondary);padding:2px 8px;border-radius:10px;font-size:11px;min-width:22px;text-align:center}.columnAddBtn{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;transition:color .15s}.columnAddBtn:hover{color:var(--accent)}.columnBody{padding:8px;display:flex;flex-direction:column;gap:6px;min-height:60px;flex:1}.emptyColumn{display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px;flex:1}.taskCard{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 14px;cursor:pointer;transition:border-color .15s,background .15s}.taskCard:hover{border-color:var(--border-light);background:var(--bg-hover)}.taskCard.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.taskCardHeader{display:flex;align-items:flex-start;gap:8px;margin-bottom:6px}.taskTitle{font-size:13px;font-weight:500;line-height:1.4;flex:1}.taskDescription{font-size:12px;color:var(--text-muted);line-height:1.4;margin-bottom:10px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.taskFooter{display:flex;align-items:center;justify-content:space-between;gap:8px}.taskFooterLeft{display:flex;align-items:center;gap:8px;min-width:0}.assigneeAvatar{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#fff;flex-shrink:0}.assigneeName{font-size:12px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.projectTag{font-size:11px;padding:2px 8px;border-radius:4px;background:#6366f126;color:var(--accent);white-space:nowrap}.timestamp{font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0}.priorityDot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:4px}.priorityDot.urgent{background:var(--priority-urgent)}.priorityDot.high{background:var(--priority-high)}.priorityDot.medium{background:var(--priority-medium)}.priorityDot.low{background:var(--priority-low)}.taskDetailOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0006;z-index:9}.taskDetail{position:fixed;top:0;right:0;width:420px;height:100vh;background:var(--bg-secondary);border-left:1px solid var(--border);padding:24px;overflow-y:auto;z-index:10}.taskDetail .closeBtn{position:absolute;top:16px;right:16px;background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:4px 10px;border-radius:6px;font-size:14px}.taskDetail .closeBtn:hover{border-color:var(--accent);color:var(--text-primary)}.detailTitle{font-size:18px;font-weight:600;margin-bottom:20px;padding-right:40px;line-height:1.4}.detailField{margin-bottom:16px}.detailLabel{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}.detailValue{font-size:14px}.detailActions{display:flex;gap:8px;margin-top:24px;padding-top:16px;border-top:1px solid var(--border)}.detailSelect{background:var(--bg-card);border:1px solid var(--border);color:var(--text-primary);padding:6px 12px;border-radius:6px;font-size:13px;flex:1}.detailSelect:focus{outline:none;border-color:var(--accent)}.deleteBtn{background:#ef44441a;border:1px solid rgba(239,68,68,.3);color:#ef4444;padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all .15s}.deleteBtn:hover{background:#ef444433;border-color:#ef444480}.modalOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:20}.modal{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:28px;width:480px;max-width:90vw;max-height:90vh;overflow-y:auto}.modal h2{font-size:18px;font-weight:600;margin-bottom:20px}.formField{margin-bottom:16px}.formLabel{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500}.formInput{width:100%;background:var(--bg-card);border:1px solid var(--border);color:var(--text-primary);padding:8px 12px;border-radius:6px;font-size:14px;font-family:inherit}.formInput:focus{outline:none;border-color:var(--accent)}textarea.formInput{resize:vertical;min-height:80px}select.formInput{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:28px;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.formRow{display:grid;grid-template-columns:1fr 1fr;gap:12px}.modalActions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}.btnSecondary{background:var(--bg-card);border:1px solid var(--border);color:var(--text-secondary);padding:8px 18px;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s}.btnSecondary:hover{border-color:var(--border-light);color:var(--text-primary)}.btnPrimary{background:var(--accent);border:none;color:#fff;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}.btnPrimary:hover{background:var(--accent-hover)}.btnPrimary:disabled{opacity:.5;cursor:not-allowed}.appLayout{display:grid;grid-template-columns:1fr;gap:0}.loading{display:flex;align-items:center;justify-content:center;padding:40px;color:var(--text-secondary)}.errorBanner{background:#ef44441a;border:1px solid rgba(239,68,68,.3);color:#ef4444;padding:10px 16px;border-radius:8px;font-size:13px;margin-bottom:12px}.wsIndicator{width:8px;height:8px;border-radius:50%;display:inline-block}.wsIndicator.connected{background:var(--col-done)}.wsIndicator.disconnected{background:var(--text-muted)}