@townco/ui 0.1.37 → 0.1.38

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 (47) hide show
  1. package/dist/core/hooks/use-chat-messages.d.ts +2 -0
  2. package/dist/core/hooks/use-chat-messages.js +19 -1
  3. package/dist/core/hooks/use-tool-calls.d.ts +2 -0
  4. package/dist/core/lib/logger.d.ts +0 -35
  5. package/dist/core/lib/logger.js +25 -108
  6. package/dist/core/schemas/chat.d.ts +4 -0
  7. package/dist/core/schemas/tool-call.d.ts +2 -0
  8. package/dist/core/schemas/tool-call.js +4 -0
  9. package/dist/gui/components/ChatEmptyState.d.ts +6 -0
  10. package/dist/gui/components/ChatEmptyState.js +2 -2
  11. package/dist/gui/components/ChatInput.js +1 -1
  12. package/dist/gui/components/ChatLayout.js +102 -8
  13. package/dist/gui/components/ChatPanelTabContent.d.ts +1 -1
  14. package/dist/gui/components/ChatPanelTabContent.js +10 -3
  15. package/dist/gui/components/ChatView.js +37 -13
  16. package/dist/gui/components/ContextUsageButton.d.ts +7 -0
  17. package/dist/gui/components/ContextUsageButton.js +18 -0
  18. package/dist/gui/components/FileSystemItem.js +6 -7
  19. package/dist/gui/components/FileSystemView.js +3 -3
  20. package/dist/gui/components/InlineToolCallSummary.d.ts +14 -0
  21. package/dist/gui/components/InlineToolCallSummary.js +110 -0
  22. package/dist/gui/components/InlineToolCallSummaryACP.d.ts +15 -0
  23. package/dist/gui/components/InlineToolCallSummaryACP.js +90 -0
  24. package/dist/gui/components/Response.js +2 -2
  25. package/dist/gui/components/SourceListItem.js +9 -1
  26. package/dist/gui/components/TodoListItem.js +19 -2
  27. package/dist/gui/components/ToolCall.js +21 -2
  28. package/dist/gui/components/Tooltip.d.ts +7 -0
  29. package/dist/gui/components/Tooltip.js +10 -0
  30. package/dist/gui/components/index.d.ts +4 -0
  31. package/dist/gui/components/index.js +5 -0
  32. package/dist/gui/components/tool-call-summary.d.ts +44 -0
  33. package/dist/gui/components/tool-call-summary.js +67 -0
  34. package/dist/gui/data/mockSourceData.d.ts +10 -0
  35. package/dist/gui/data/mockSourceData.js +40 -0
  36. package/dist/gui/data/mockTodoData.d.ts +10 -0
  37. package/dist/gui/data/mockTodoData.js +35 -0
  38. package/dist/gui/examples/FileSystemDemo.d.ts +5 -0
  39. package/dist/gui/examples/FileSystemDemo.js +24 -0
  40. package/dist/gui/examples/FileSystemExample.d.ts +17 -0
  41. package/dist/gui/examples/FileSystemExample.js +94 -0
  42. package/dist/sdk/schemas/session.d.ts +2 -0
  43. package/dist/sdk/transports/http.js +12 -0
  44. package/dist/sdk/transports/stdio.js +13 -0
  45. package/package.json +3 -3
  46. package/dist/tui/components/LogsPanel.d.ts +0 -5
  47. package/dist/tui/components/LogsPanel.js +0 -29
@@ -16,6 +16,8 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
16
16
  title: string;
17
17
  kind: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other";
18
18
  status: "pending" | "in_progress" | "completed" | "failed";
19
+ prettyName?: string | undefined;
20
+ icon?: string | undefined;
19
21
  contentPosition?: number | undefined;
20
22
  locations?: {
21
23
  path: string;
@@ -38,6 +38,8 @@ export function useChatMessages(client, startSession) {
38
38
  sessionId: newSessionId,
39
39
  });
40
40
  }
41
+ // Create assistant message ID outside try block so it's accessible in catch
42
+ const assistantMessageId = `msg_${Date.now()}_assistant`;
41
43
  try {
42
44
  // Start streaming and track time immediately
43
45
  const startTime = Date.now();
@@ -53,7 +55,6 @@ export function useChatMessages(client, startSession) {
53
55
  };
54
56
  addMessage(userMessage);
55
57
  // Create placeholder for assistant message BEFORE sending
56
- const assistantMessageId = `msg_${Date.now()}_assistant`;
57
58
  const assistantMessage = {
58
59
  id: assistantMessageId,
59
60
  role: "assistant",
@@ -75,6 +76,7 @@ export function useChatMessages(client, startSession) {
75
76
  });
76
77
  // Listen for streaming chunks
77
78
  let accumulatedContent = "";
79
+ let streamCompleted = false;
78
80
  for await (const chunk of messageStream) {
79
81
  if (chunk.tokenUsage) {
80
82
  logger.debug("chunk.tokenUsage", {
@@ -91,6 +93,7 @@ export function useChatMessages(client, startSession) {
91
93
  });
92
94
  setIsStreaming(false);
93
95
  setStreamingStartTime(null); // Clear global streaming start time
96
+ streamCompleted = true;
94
97
  break;
95
98
  }
96
99
  else {
@@ -106,12 +109,27 @@ export function useChatMessages(client, startSession) {
106
109
  }
107
110
  }
108
111
  }
112
+ // Ensure streaming state is cleared even if no explicit isComplete was received
113
+ if (!streamCompleted) {
114
+ logger.warn("Stream ended without isComplete flag");
115
+ updateMessage(assistantMessageId, {
116
+ isStreaming: false,
117
+ streamingStartTime: undefined,
118
+ });
119
+ setIsStreaming(false);
120
+ setStreamingStartTime(null);
121
+ }
109
122
  }
110
123
  catch (error) {
111
124
  const message = error instanceof Error ? error.message : String(error);
112
125
  setError(message);
113
126
  setIsStreaming(false);
114
127
  setStreamingStartTime(null); // Clear streaming start time on error
128
+ // Ensure the assistant message isStreaming is set to false
129
+ updateMessage(assistantMessageId, {
130
+ isStreaming: false,
131
+ streamingStartTime: undefined,
132
+ });
115
133
  }
116
134
  }, [
117
135
  client,
@@ -14,6 +14,8 @@ export declare function useToolCalls(client: AcpClient | null): {
14
14
  title: string;
15
15
  kind: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other";
16
16
  status: "pending" | "in_progress" | "completed" | "failed";
17
+ prettyName?: string | undefined;
18
+ icon?: string | undefined;
17
19
  contentPosition?: number | undefined;
18
20
  locations?: {
19
21
  path: string;
@@ -1,46 +1,12 @@
1
1
  /**
2
2
  * Browser-compatible logger
3
3
  * Outputs structured JSON logs to console with color-coding
4
- * Also captures logs to a global store for in-app viewing
5
- * In Node.js environment with logsDir option, also writes to .logs/ directory
6
4
  */
7
5
  export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
8
- export interface LogEntry {
9
- id: string;
10
- timestamp: string;
11
- level: LogLevel;
12
- service: string;
13
- message: string;
14
- metadata?: Record<string, unknown>;
15
- }
16
- /**
17
- * Get all captured logs
18
- */
19
- export declare function getCapturedLogs(): LogEntry[];
20
- /**
21
- * Clear all captured logs
22
- */
23
- export declare function clearCapturedLogs(): void;
24
- /**
25
- * Subscribe to log updates
26
- */
27
- type LogSubscriber = (entry: LogEntry) => void;
28
- export declare function subscribeToLogs(callback: LogSubscriber): () => void;
29
- /**
30
- * Configure global logs directory for file writing
31
- * Must be called before creating any loggers (typically at TUI startup)
32
- */
33
- export declare function configureLogsDir(logsDir: string): void;
34
6
  export declare class Logger {
35
7
  private service;
36
8
  private minLevel;
37
- private logFilePath?;
38
- private logsDir?;
39
- private writeQueue;
40
- private isWriting;
41
9
  constructor(service: string, minLevel?: LogLevel);
42
- private setupFileLogging;
43
- private writeToFile;
44
10
  private shouldLog;
45
11
  private log;
46
12
  trace(message: string, metadata?: Record<string, unknown>): void;
@@ -56,4 +22,3 @@ export declare class Logger {
56
22
  * @param minLevel - Minimum log level to display (default: "debug")
57
23
  */
58
24
  export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
59
- export {};
@@ -1,45 +1,7 @@
1
1
  /**
2
2
  * Browser-compatible logger
3
3
  * Outputs structured JSON logs to console with color-coding
4
- * Also captures logs to a global store for in-app viewing
5
- * In Node.js environment with logsDir option, also writes to .logs/ directory
6
4
  */
7
- // Check if running in Node.js
8
- const isNode = typeof process !== "undefined" && process.versions?.node;
9
- // Global logs directory configuration (set once at app startup for TUI)
10
- let globalLogsDir;
11
- // Global log store
12
- const globalLogStore = [];
13
- let logIdCounter = 0;
14
- /**
15
- * Get all captured logs
16
- */
17
- export function getCapturedLogs() {
18
- return [...globalLogStore];
19
- }
20
- /**
21
- * Clear all captured logs
22
- */
23
- export function clearCapturedLogs() {
24
- globalLogStore.length = 0;
25
- }
26
- const logSubscribers = new Set();
27
- export function subscribeToLogs(callback) {
28
- logSubscribers.add(callback);
29
- return () => logSubscribers.delete(callback);
30
- }
31
- function notifyLogSubscribers(entry) {
32
- for (const callback of logSubscribers) {
33
- callback(entry);
34
- }
35
- }
36
- /**
37
- * Configure global logs directory for file writing
38
- * Must be called before creating any loggers (typically at TUI startup)
39
- */
40
- export function configureLogsDir(logsDir) {
41
- globalLogsDir = logsDir;
42
- }
43
5
  const LOG_LEVELS = {
44
6
  trace: 0,
45
7
  debug: 1,
@@ -56,7 +18,7 @@ const _LOG_COLORS = {
56
18
  error: "#EF4444", // red
57
19
  fatal: "#DC2626", // dark red
58
20
  };
59
- const _LOG_STYLES = {
21
+ const LOG_STYLES = {
60
22
  trace: "color: #6B7280",
61
23
  debug: "color: #3B82F6; font-weight: bold",
62
24
  info: "color: #10B981; font-weight: bold",
@@ -67,10 +29,6 @@ const _LOG_STYLES = {
67
29
  export class Logger {
68
30
  service;
69
31
  minLevel;
70
- logFilePath;
71
- logsDir;
72
- writeQueue = [];
73
- isWriting = false;
74
32
  constructor(service, minLevel = "debug") {
75
33
  this.service = service;
76
34
  this.minLevel = minLevel;
@@ -79,48 +37,6 @@ export class Logger {
79
37
  process.env?.NODE_ENV === "production") {
80
38
  this.minLevel = "info";
81
39
  }
82
- // Note: File logging setup is done lazily in log() method
83
- // This allows loggers created before configureLogsDir() to still write to files
84
- }
85
- setupFileLogging() {
86
- if (!isNode || !globalLogsDir)
87
- return;
88
- try {
89
- // Dynamic import for Node.js modules
90
- const path = require("node:path");
91
- const fs = require("node:fs");
92
- this.logsDir = globalLogsDir;
93
- this.logFilePath = path.join(this.logsDir, `${this.service}.log`);
94
- // Create logs directory if it doesn't exist
95
- if (!fs.existsSync(this.logsDir)) {
96
- fs.mkdirSync(this.logsDir, { recursive: true });
97
- }
98
- }
99
- catch (_error) {
100
- // Silently fail if we can't set up file logging
101
- }
102
- }
103
- async writeToFile(content) {
104
- if (!this.logFilePath || !isNode)
105
- return;
106
- this.writeQueue.push(content);
107
- if (this.isWriting) {
108
- return;
109
- }
110
- this.isWriting = true;
111
- while (this.writeQueue.length > 0) {
112
- const batch = this.writeQueue.splice(0, this.writeQueue.length);
113
- const data = `${batch.join("\n")}\n`;
114
- try {
115
- // Dynamic import for Node.js modules
116
- const fs = require("node:fs");
117
- await fs.promises.appendFile(this.logFilePath, data, "utf-8");
118
- }
119
- catch (_error) {
120
- // Silently fail
121
- }
122
- }
123
- this.isWriting = false;
124
40
  }
125
41
  shouldLog(level) {
126
42
  return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
@@ -130,37 +46,38 @@ export class Logger {
130
46
  return;
131
47
  }
132
48
  const entry = {
133
- id: `log_${++logIdCounter}`,
134
49
  timestamp: new Date().toISOString(),
135
50
  level,
136
51
  service: this.service,
137
52
  message,
138
53
  ...(metadata && { metadata }),
139
54
  };
140
- // Store in global log store
141
- globalLogStore.push(entry);
142
- // Notify subscribers
143
- notifyLogSubscribers(entry);
144
- // Write to file in Node.js (for logs tab to read)
145
- // Lazily set up file logging if globalLogsDir was configured after this logger was created
146
- if (isNode && !this.logFilePath && globalLogsDir) {
147
- this.setupFileLogging();
55
+ // Console output with color-coding
56
+ const style = LOG_STYLES[level];
57
+ const levelUpper = level.toUpperCase().padEnd(5);
58
+ if (typeof console !== "undefined") {
59
+ // Format: [timestamp] [SERVICE] [LEVEL] message
60
+ const prefix = `%c[${entry.timestamp}] [${this.service}] [${levelUpper}]`;
61
+ const msg = metadata ? `${message} %o` : message;
62
+ switch (level) {
63
+ case "trace":
64
+ case "debug":
65
+ case "info":
66
+ console.log(prefix, style, msg, ...(metadata ? [metadata] : []));
67
+ break;
68
+ case "warn":
69
+ console.warn(prefix, style, msg, ...(metadata ? [metadata] : []));
70
+ break;
71
+ case "error":
72
+ case "fatal":
73
+ console.error(prefix, style, msg, ...(metadata ? [metadata] : []));
74
+ break;
75
+ }
148
76
  }
149
- if (isNode && this.logFilePath) {
150
- // Write as JSON without the id field (to match expected format)
151
- const fileEntry = {
152
- timestamp: entry.timestamp,
153
- level: entry.level,
154
- service: entry.service,
155
- message: entry.message,
156
- ...(entry.metadata && { metadata: entry.metadata }),
157
- };
158
- this.writeToFile(JSON.stringify(fileEntry)).catch(() => {
159
- // Silently fail
160
- });
77
+ // Also log the structured JSON for debugging
78
+ if (level === "fatal" || level === "error") {
79
+ console.debug("Structured log:", JSON.stringify(entry));
161
80
  }
162
- // No console output - logs are only captured and displayed in UI
163
- // This prevents logs from polluting stdout/stderr in TUI mode
164
81
  }
165
82
  trace(message, metadata) {
166
83
  this.log("trace", message, metadata);
@@ -20,6 +20,8 @@ export declare const DisplayMessage: z.ZodObject<{
20
20
  toolCalls: z.ZodOptional<z.ZodArray<z.ZodObject<{
21
21
  id: z.ZodString;
22
22
  title: z.ZodString;
23
+ prettyName: z.ZodOptional<z.ZodString>;
24
+ icon: z.ZodOptional<z.ZodString>;
23
25
  kind: z.ZodEnum<{
24
26
  read: "read";
25
27
  edit: "edit";
@@ -116,6 +118,8 @@ export declare const ChatSessionState: z.ZodObject<{
116
118
  toolCalls: z.ZodOptional<z.ZodArray<z.ZodObject<{
117
119
  id: z.ZodString;
118
120
  title: z.ZodString;
121
+ prettyName: z.ZodOptional<z.ZodString>;
122
+ icon: z.ZodOptional<z.ZodString>;
119
123
  kind: z.ZodEnum<{
120
124
  read: "read";
121
125
  edit: "edit";
@@ -71,6 +71,8 @@ export type ToolCallContentBlock = z.infer<typeof ToolCallContentBlockSchema>;
71
71
  export declare const ToolCallSchema: z.ZodObject<{
72
72
  id: z.ZodString;
73
73
  title: z.ZodString;
74
+ prettyName: z.ZodOptional<z.ZodString>;
75
+ icon: z.ZodOptional<z.ZodString>;
74
76
  kind: z.ZodEnum<{
75
77
  read: "read";
76
78
  edit: "edit";
@@ -75,6 +75,10 @@ export const ToolCallSchema = z.object({
75
75
  id: z.string(),
76
76
  /** Human-readable description of the operation */
77
77
  title: z.string(),
78
+ /** Optional pretty name for the tool (e.g. "Web Search" instead of "web_search") */
79
+ prettyName: z.string().optional(),
80
+ /** Optional icon identifier for the tool (e.g. "Globe", "Search", "Edit") */
81
+ icon: z.string().optional(),
78
82
  /** Category for UI presentation */
79
83
  kind: ToolCallKindSchema,
80
84
  /** Current execution status */
@@ -14,5 +14,11 @@ export interface ChatEmptyStateProps extends React.HTMLAttributes<HTMLDivElement
14
14
  onPromptClick?: (prompt: string) => void;
15
15
  /** Callback when guide is clicked */
16
16
  onGuideClick?: () => void;
17
+ /** Callback when "Open Files" is clicked */
18
+ onOpenFiles?: () => void;
19
+ /** Callback when hovering over a prompt */
20
+ onPromptHover?: (prompt: string) => void;
21
+ /** Callback when mouse leaves a prompt */
22
+ onPromptLeave?: () => void;
17
23
  }
18
24
  export declare const ChatEmptyState: React.ForwardRefExoticComponent<ChatEmptyStateProps & React.RefAttributes<HTMLDivElement>>;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { cn } from "../lib/utils.js";
5
- export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl, guideText = "Guide", suggestedPrompts = [], onPromptClick, onGuideClick, className, ...props }, ref) => {
5
+ export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl, guideText = "Guide", suggestedPrompts = [], onPromptClick, onGuideClick, onOpenFiles, onPromptHover, onPromptLeave, className, ...props }, ref) => {
6
6
  const handlePromptClick = (prompt) => {
7
7
  onPromptClick?.(prompt);
8
8
  };
@@ -17,6 +17,6 @@ export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl,
17
17
  for (let i = 0; i < suggestedPrompts.length; i += 2) {
18
18
  promptRows.push(suggestedPrompts.slice(i, i + 2));
19
19
  }
20
- return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-start gap-6", className), ...props, children: [_jsx("h3", { className: "text-heading-4 text-text-primary", children: title }), _jsx("p", { className: "text-subheading text-text-secondary max-w-prose", children: description }), (guideUrl || onGuideClick) && (_jsxs("button", { type: "button", onClick: handleGuideClick, className: "flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-accent transition-colors", children: [_jsx("span", { className: "text-paragraph-sm font-medium leading-normal text-text-primary", children: guideText }), _jsx(ChevronRight, { className: "size-4 text-text-primary" })] })), suggestedPrompts.length > 0 && (_jsxs("div", { className: "flex flex-col gap-3 w-full max-w-prompt-container", children: [_jsx("p", { className: "text-label text-text-tertiary", children: "Suggested Prompts" }), _jsx("div", { className: "flex flex-col gap-2.5", children: promptRows.map((row) => (_jsx("div", { className: "flex gap-2.5 items-center", children: row.map((prompt) => (_jsx("button", { type: "button", onClick: () => handlePromptClick(prompt), className: "flex-1 flex items-start gap-2 p-3 bg-secondary hover:bg-secondary/80 rounded-2xl transition-colors min-w-0", children: _jsx("span", { className: "text-paragraph font-normal leading-normal text-text-tertiary truncate", children: prompt }) }, prompt))) }, row.join("-")))) })] }))] }));
20
+ return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-start", className), ...props, children: [_jsx("h3", { className: "text-heading-4 text-text-primary mb-6", children: title }), _jsx("p", { className: "text-subheading text-text-secondary max-w-prose mb-6", children: description }), onOpenFiles && (_jsxs("button", { type: "button", onClick: onOpenFiles, className: "inline-flex items-center gap-1 py-1.5 pr-3 -ml-3 pl-3 rounded-lg hover:bg-accent transition-colors mb-6", children: [_jsx("span", { className: "text-paragraph-sm-medium text-foreground tracking-wide leading-none", children: "View Files" }), _jsx(ChevronRight, { className: "size-4 text-foreground shrink-0" })] })), (guideUrl || onGuideClick) && (_jsxs("button", { type: "button", onClick: handleGuideClick, className: "inline-flex items-center gap-1 py-1.5 pr-3 -ml-3 pl-3 rounded-lg hover:bg-accent transition-colors", children: [_jsx("span", { className: "text-paragraph-sm-medium text-foreground tracking-wide leading-none", children: guideText }), _jsx(ChevronRight, { className: "size-4 text-foreground shrink-0" })] })), suggestedPrompts.length > 0 && (_jsxs("div", { className: "flex flex-col gap-3 w-full max-w-prompt-container", children: [_jsx("p", { className: "text-label text-text-tertiary", children: "Suggested Prompts" }), _jsx("div", { className: "flex flex-col gap-2.5", children: promptRows.map((row) => (_jsx("div", { className: "flex gap-2.5 items-center", children: row.map((prompt) => (_jsx("button", { type: "button", onClick: () => handlePromptClick(prompt), onMouseEnter: () => onPromptHover?.(prompt), onMouseLeave: () => onPromptLeave?.(), className: "flex-1 flex items-start gap-2 p-3 bg-secondary hover:bg-secondary/80 rounded-2xl transition-colors min-w-0", children: _jsx("span", { className: "text-paragraph font-normal leading-normal text-text-tertiary truncate", children: prompt }) }, prompt))) }, row.join("-")))) })] }))] }));
21
21
  });
22
22
  ChatEmptyState.displayName = "ChatEmptyState";
@@ -206,7 +206,7 @@ const ChatInputField = React.forwardRef(({ asChild = false, className, onKeyDown
206
206
  if (asChild && React.isValidElement(children)) {
207
207
  return React.cloneElement(children, fieldProps);
208
208
  }
209
- return (_jsx("textarea", { ...fieldProps, className: cn("w-full resize-none rounded-none border-none p-4 shadow-none", "outline-none ring-0 field-sizing-content max-h-[6lh]", "bg-transparent dark:bg-transparent focus-visible:ring-0", "text-paragraph-sm placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", className) }));
209
+ return (_jsx("textarea", { ...fieldProps, rows: 1, className: cn("w-full resize-none rounded-none border-none p-4 shadow-none", "outline-none ring-0 max-h-[6lh] min-h-[44px]", "bg-transparent dark:bg-transparent focus-visible:ring-0", "text-paragraph-sm placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", className) }));
210
210
  });
211
211
  ChatInputField.displayName = "ChatInput.Field";
212
212
  const ChatInputSubmit = React.forwardRef(({ asChild = false, className, disabled: disabledProp, children, ...props }, ref) => {
@@ -41,38 +41,118 @@ ChatLayoutBody.displayName = "ChatLayout.Body";
41
41
  const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, ...props }, ref) => {
42
42
  const [showScrollButton, setShowScrollButton] = React.useState(false);
43
43
  const scrollContainerRef = React.useRef(null);
44
+ const wasAtBottomRef = React.useRef(true); // Track if user was at bottom before content update
45
+ const isAutoScrollingRef = React.useRef(false); // Track if we're programmatically scrolling
44
46
  // Merge refs
45
47
  React.useImperativeHandle(ref, () => scrollContainerRef.current);
46
48
  // Check if user is at bottom of scroll
47
49
  const checkScrollPosition = React.useCallback(() => {
48
50
  const container = scrollContainerRef.current;
49
51
  if (!container)
50
- return;
52
+ return false;
51
53
  const { scrollTop, scrollHeight, clientHeight } = container;
52
54
  const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
53
55
  const isAtBottom = distanceFromBottom < 100; // 100px threshold
54
56
  setShowScrollButton(!isAtBottom && showScrollToBottom);
55
57
  onScrollChange?.(isAtBottom);
58
+ return isAtBottom;
56
59
  }, [onScrollChange, showScrollToBottom]);
57
60
  // Handle scroll events
58
61
  const handleScroll = React.useCallback(() => {
59
- checkScrollPosition();
62
+ // If this is a programmatic scroll, don't update wasAtBottomRef
63
+ if (isAutoScrollingRef.current) {
64
+ return;
65
+ }
66
+ // This is a user-initiated scroll, update the position
67
+ const isAtBottom = checkScrollPosition();
68
+ wasAtBottomRef.current = isAtBottom;
60
69
  }, [checkScrollPosition]);
61
70
  // Scroll to bottom function
62
- const scrollToBottom = React.useCallback(() => {
71
+ const scrollToBottom = React.useCallback((smooth = true) => {
63
72
  const container = scrollContainerRef.current;
64
73
  if (!container)
65
74
  return;
75
+ // Mark that we're about to programmatically scroll
76
+ isAutoScrollingRef.current = true;
77
+ wasAtBottomRef.current = true; // Set immediately for instant scrolls
66
78
  container.scrollTo({
67
79
  top: container.scrollHeight,
68
- behavior: "smooth",
80
+ behavior: smooth ? "smooth" : "auto",
69
81
  });
82
+ // Clear the flag after scroll completes
83
+ // For instant scrolling, clear immediately; for smooth, wait
84
+ setTimeout(() => {
85
+ isAutoScrollingRef.current = false;
86
+ }, smooth ? 300 : 0);
70
87
  }, []);
71
- // Check scroll position on mount and when children change
88
+ // Auto-scroll when content changes if user was at bottom
89
+ React.useEffect(() => {
90
+ const container = scrollContainerRef.current;
91
+ if (!container)
92
+ return;
93
+ // If user was at the bottom, scroll to new content
94
+ if (wasAtBottomRef.current && !isAutoScrollingRef.current) {
95
+ // Use requestAnimationFrame to ensure DOM has updated
96
+ requestAnimationFrame(() => {
97
+ scrollToBottom(false); // Use instant scroll for streaming to avoid jarring smooth animations
98
+ });
99
+ }
100
+ // Update scroll position state (but don't change wasAtBottomRef if we're auto-scrolling)
101
+ if (!isAutoScrollingRef.current) {
102
+ checkScrollPosition();
103
+ }
104
+ }, [children, scrollToBottom, checkScrollPosition]);
105
+ // Check scroll position on mount
72
106
  React.useEffect(() => {
73
- checkScrollPosition();
107
+ if (!isAutoScrollingRef.current) {
108
+ const isAtBottom = checkScrollPosition();
109
+ wasAtBottomRef.current = isAtBottom;
110
+ }
74
111
  }, [checkScrollPosition]);
75
- return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsx("div", { ref: scrollContainerRef, className: cn("h-full overflow-y-auto flex flex-col", className), onScroll: handleScroll, ...props, children: _jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }) }), showScrollButton && (_jsx("button", { type: "button", onClick: scrollToBottom, className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
112
+ // Detect user interaction with scroll area (wheel, touch) - IMMEDIATELY break auto-scroll
113
+ const handleUserInteraction = React.useCallback(() => {
114
+ // Immediately mark that user is interacting
115
+ isAutoScrollingRef.current = false;
116
+ // For wheel/touch events, temporarily break auto-scroll
117
+ // The actual scroll event will update wasAtBottomRef properly
118
+ // This prevents the race condition where content updates before scroll completes
119
+ const container = scrollContainerRef.current;
120
+ if (!container)
121
+ return;
122
+ // Check current position BEFORE the scroll happens
123
+ const { scrollTop, scrollHeight, clientHeight } = container;
124
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
125
+ // If user is not currently at the bottom, definitely break auto-scroll
126
+ if (distanceFromBottom >= 100) {
127
+ wasAtBottomRef.current = false;
128
+ }
129
+ // If they are at bottom, the scroll event will determine if they stay there
130
+ }, []);
131
+ // Handle keyboard navigation
132
+ const handleKeyDown = React.useCallback((e) => {
133
+ // If user presses arrow keys, page up/down, home/end - they're scrolling
134
+ const scrollKeys = [
135
+ "ArrowUp",
136
+ "ArrowDown",
137
+ "PageUp",
138
+ "PageDown",
139
+ "Home",
140
+ "End",
141
+ ];
142
+ if (scrollKeys.includes(e.key)) {
143
+ isAutoScrollingRef.current = false;
144
+ // Check position on next frame after the scroll happens
145
+ requestAnimationFrame(() => {
146
+ const container = scrollContainerRef.current;
147
+ if (!container)
148
+ return;
149
+ const { scrollTop, scrollHeight, clientHeight } = container;
150
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
151
+ wasAtBottomRef.current = distanceFromBottom < 100;
152
+ });
153
+ }
154
+ }, []);
155
+ return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsx("div", { ref: scrollContainerRef, className: cn("h-full overflow-y-auto flex flex-col", className), onScroll: handleScroll, onWheel: handleUserInteraction, onTouchStart: handleUserInteraction, onKeyDown: handleKeyDown, tabIndex: 0, ...props, children: _jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }) }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom(true), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
76
156
  });
77
157
  ChatLayoutMessages.displayName = "ChatLayout.Messages";
78
158
  const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
@@ -88,10 +168,24 @@ const ChatLayoutSidebar = React.forwardRef(({ className, children, ...props }, r
88
168
  ChatLayoutSidebar.displayName = "ChatLayout.Sidebar";
89
169
  const ChatLayoutAside = React.forwardRef(({ breakpoint = "lg", className, children, ...props }, ref) => {
90
170
  const { panelSize } = useChatLayoutContext();
171
+ const [minSizePercent, setMinSizePercent] = React.useState(25);
172
+ // Convert 400px minimum to percentage based on window width
173
+ React.useEffect(() => {
174
+ const updateMinSize = () => {
175
+ const minPixels = 400;
176
+ const minPercent = (minPixels / window.innerWidth) * 100;
177
+ setMinSizePercent(Math.max(minPercent, 25)); // Never less than 25% or 400px
178
+ };
179
+ updateMinSize();
180
+ window.addEventListener("resize", updateMinSize);
181
+ return () => {
182
+ window.removeEventListener("resize", updateMinSize);
183
+ };
184
+ }, []);
91
185
  // Hidden state - don't render
92
186
  if (panelSize === "hidden")
93
187
  return null;
94
- return (_jsxs(_Fragment, { children: [_jsx(ResizableHandle, { withHandle: true }), _jsx(ResizablePanel, { defaultSize: 25, minSize: 15, maxSize: 50, children: _jsx("div", { ref: ref, className: cn(
188
+ return (_jsxs(_Fragment, { children: [_jsx(ResizableHandle, { withHandle: true, className: "group-hover:opacity-100 opacity-0 transition-opacity" }), _jsx(ResizablePanel, { defaultSize: 25, minSize: minSizePercent, maxSize: 35, className: "group", children: _jsx("div", { ref: ref, className: cn(
95
189
  // Hidden by default, visible at breakpoint
96
190
  "hidden h-full border-l border-border bg-card overflow-y-auto transition-all duration-300",
97
191
  // Breakpoint visibility
@@ -7,7 +7,7 @@ import type { TodoItem } from "./TodoListItem.js";
7
7
  * Following component architecture best practices
8
8
  */
9
9
  export interface TodoTabContentProps extends React.HTMLAttributes<HTMLDivElement> {
10
- todos: TodoItem[];
10
+ todos?: TodoItem[];
11
11
  }
12
12
  export declare const TodoTabContent: React.ForwardRefExoticComponent<TodoTabContentProps & React.RefAttributes<HTMLDivElement>>;
13
13
  export interface FilesTabContentProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -1,10 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { mockSourceData } from "../data/mockSourceData.js";
4
+ import { mockTodoData } from "../data/mockTodoData.js";
3
5
  import { cn } from "../lib/utils.js";
4
6
  import { FileSystemView } from "./FileSystemView.js";
5
7
  import { SourceListItem } from "./SourceListItem.js";
8
+ import { TodoList } from "./TodoList.js";
6
9
  export const TodoTabContent = React.forwardRef(({ todos, className, ...props }, ref) => {
7
- return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: todos.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-full min-h-[200px]", children: _jsx("p", { className: "text-paragraph-sm text-muted-foreground", children: "No todos yet" }) })) : (todos.map((todo) => (_jsx("div", { className: "text-paragraph-sm", children: todo.text }, todo.id)))) }));
10
+ // Use mock data if no todos provided or if empty array
11
+ const displayTodos = todos && todos.length > 0 ? todos : mockTodoData;
12
+ return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: _jsx(TodoList, { todos: displayTodos }) }));
8
13
  });
9
14
  TodoTabContent.displayName = "TodoTabContent";
10
15
  export const FilesTabContent = React.forwardRef(({ files = [], provider, onFileSelect, className, ...props }, ref) => {
@@ -29,8 +34,10 @@ export const FilesTabContent = React.forwardRef(({ files = [], provider, onFileS
29
34
  }, onDownload: handleDownload, onRename: handleRename, onDelete: handleDelete, className: "h-full" }) }));
30
35
  });
31
36
  FilesTabContent.displayName = "FilesTabContent";
32
- export const SourcesTabContent = React.forwardRef(({ sources = [], className, ...props }, ref) => {
33
- return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: sources.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-full min-h-[200px]", children: _jsx("p", { className: "text-paragraph-sm text-muted-foreground", children: "No sources available" }) })) : (sources.map((source) => (_jsx(SourceListItem, { source: source }, source.id)))) }));
37
+ export const SourcesTabContent = React.forwardRef(({ sources, className, ...props }, ref) => {
38
+ // Use mock data if no sources provided or if empty array
39
+ const displaySources = sources && sources.length > 0 ? sources : mockSourceData;
40
+ return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: displaySources.map((source) => (_jsx(SourceListItem, { source: source }, source.id))) }));
34
41
  });
35
42
  SourcesTabContent.displayName = "SourcesTabContent";
36
43
  export const DatabaseTabContent = React.forwardRef(({ data, className, ...props }, ref) => {