tweaking the research implementation, notes now can be taken in trminal and active workspace selector should be functional
This commit is contained in:
@@ -293,9 +293,9 @@ function App() {
|
||||
onClearPortfolioAction={handleClearPortfolioAction}
|
||||
resetCommandIndex={resetCommandIndex}
|
||||
portfolioWorkflow={activePortfolioWorkflow}
|
||||
onOpenResearchContext={(intent) => {
|
||||
void handleOpenResearch(intent);
|
||||
}}
|
||||
researchWorkspaces={researchWorkspaces.workspaces}
|
||||
activeResearchWorkspaceId={researchWorkspaces.activeWorkspaceId}
|
||||
onCaptureResearchNote={handleCaptureResearchNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ResearchMode } from './ResearchMode';
|
||||
import type { ResearchWorkspace } from '../../types/research';
|
||||
|
||||
const createWorkspace = (id: string, ticker: string): ResearchWorkspace => ({
|
||||
id,
|
||||
name: `${ticker} Research`,
|
||||
primaryTicker: ticker,
|
||||
scope: 'single_company',
|
||||
stage: 'capture',
|
||||
defaultView: 'canvas',
|
||||
pinnedNoteIds: [],
|
||||
archived: false,
|
||||
createdAt: '2026-04-09T10:00:00Z',
|
||||
updatedAt: '2026-04-09T10:00:00Z',
|
||||
});
|
||||
|
||||
describe('ResearchMode', () => {
|
||||
it('renders empty state when no workspace is selected', () => {
|
||||
@@ -19,6 +33,25 @@ describe('ResearchMode', () => {
|
||||
);
|
||||
|
||||
expect(html).toContain('No research workspace selected');
|
||||
expect(html).toContain('Quick capture');
|
||||
expect(html).toContain('Select a research workspace');
|
||||
});
|
||||
|
||||
it('renders the selected workspace details from the active workspace id', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ResearchMode
|
||||
workspaces={[createWorkspace('workspace-1', 'AAPL'), createWorkspace('workspace-2', 'NVDA')]}
|
||||
activeWorkspaceId="workspace-2"
|
||||
navigationIntent={null}
|
||||
onSelectWorkspace={() => {}}
|
||||
onEnsureWorkspace={async () => null}
|
||||
onCaptureResearchNote={async () => {
|
||||
throw new Error('not used');
|
||||
}}
|
||||
onConsumeNavigationIntent={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('NVDA Research');
|
||||
expect(html).toContain('NVDA');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,12 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
|
||||
const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = useState(false);
|
||||
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<ResearchComposerState, 'rawText' | 'ticker'> | null>(null);
|
||||
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<
|
||||
ResearchComposerState,
|
||||
'rawText' | 'ticker'
|
||||
> & {
|
||||
contextLabel?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId) {
|
||||
@@ -137,6 +142,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
setTerminalNoteSeed({
|
||||
rawText: navigationIntent.terminalNoteSeed.rawText ?? '',
|
||||
ticker: navigationIntent.terminalNoteSeed.ticker ?? '',
|
||||
contextLabel: navigationIntent.terminalNoteSeed.contextLabel,
|
||||
});
|
||||
setIsCaptureModalOpen(true);
|
||||
}
|
||||
@@ -152,7 +158,14 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
]);
|
||||
|
||||
const projection = projectionState.projection;
|
||||
const currentWorkspace = projection?.workspace ?? workspaceState.workspace;
|
||||
const selectedWorkspaceRecord = useMemo(
|
||||
() => workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null,
|
||||
[activeWorkspaceId, workspaces],
|
||||
);
|
||||
const currentWorkspace =
|
||||
(projection?.workspace.id === activeWorkspaceId ? projection.workspace : null) ??
|
||||
(workspaceState.workspace?.id === activeWorkspaceId ? workspaceState.workspace : null) ??
|
||||
selectedWorkspaceRecord;
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
const notes = projection?.notes ?? [];
|
||||
@@ -482,7 +495,10 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
defaultWorkspaceId={activeWorkspaceId}
|
||||
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||
defaultRawText={terminalNoteSeed?.rawText}
|
||||
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
||||
contextLabel={
|
||||
terminalNoteSeed?.contextLabel ??
|
||||
(currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture')
|
||||
}
|
||||
onWorkspaceChange={onSelectWorkspace}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
|
||||
@@ -300,7 +300,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
||||
}`}
|
||||
title="Switch to Terminal (Cmd+T)"
|
||||
>
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
{/* <TerminalSquare className="h-4 w-4" /> */}
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
@@ -309,7 +309,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
||||
className="flex items-center justify-center gap-2 border border-info bg-info/10 px-3 py-2 text-center text-xs font-mono text-info"
|
||||
title="Research"
|
||||
>
|
||||
<NotebookPen className="h-4 w-4" />
|
||||
{/* <NotebookPen className="h-4 w-4" /> */}
|
||||
Research
|
||||
</button>
|
||||
<button
|
||||
@@ -321,7 +321,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
||||
: "border-term-border bg-term-surface text-term-text-tertiary hover:border-info/60 hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{/* <Settings className="h-4 w-4" /> */}
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
LayoutGrid,
|
||||
Menu,
|
||||
PanelLeft,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Settings, ChevronLeft, Briefcase, TerminalSquare, NotebookPen } from 'lucide-react';
|
||||
import { PortfolioSummary } from './PortfolioSummary';
|
||||
import { TickerHistory } from './TickerHistory';
|
||||
import { Portfolio } from '../../types/financial';
|
||||
import { TickerHistoryEntry } from '../../types/terminal';
|
||||
import React from "react";
|
||||
import {
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
Briefcase,
|
||||
TerminalSquare,
|
||||
NotebookPen,
|
||||
} from "lucide-react";
|
||||
import { PortfolioSummary } from "./PortfolioSummary";
|
||||
import { TickerHistory } from "./TickerHistory";
|
||||
import { Portfolio } from "../../types/financial";
|
||||
import { TickerHistoryEntry } from "../../types/terminal";
|
||||
|
||||
interface SidebarProps {
|
||||
onCommand: (command: string) => void;
|
||||
onOpenTerminal: () => void;
|
||||
onOpenResearch: () => void;
|
||||
onOpenSettings: () => void;
|
||||
activeView: 'terminal' | 'research' | 'settings';
|
||||
activeView: "terminal" | "research" | "settings";
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
tickerHistory: TickerHistoryEntry[];
|
||||
@@ -53,7 +59,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{/* Portfolio icon */}
|
||||
<button
|
||||
onClick={() => onCommand('/portfolio')}
|
||||
onClick={() => onCommand("/portfolio")}
|
||||
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
||||
title="Portfolio"
|
||||
>
|
||||
@@ -73,7 +79,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
||||
title={entry.company.name}
|
||||
>
|
||||
<span className={`text-xs font-mono font-bold ${isPositive ? 'text-positive' : 'text-negative'}`}>
|
||||
<span
|
||||
className={`text-xs font-mono font-bold ${isPositive ? "text-positive" : "text-negative"}`}
|
||||
>
|
||||
{entry.company.symbol.slice(0, 2)}
|
||||
</span>
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-term-highlight border border-term-border rounded text-xs text-term-text opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
||||
@@ -89,9 +97,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<button
|
||||
onClick={onOpenTerminal}
|
||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||
activeView === 'terminal'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
||||
activeView === "terminal"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||
}`}
|
||||
title="Open terminal"
|
||||
>
|
||||
@@ -100,9 +108,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<button
|
||||
onClick={onOpenResearch}
|
||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||
activeView === 'research'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
||||
activeView === "research"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||
}`}
|
||||
title="Open research"
|
||||
>
|
||||
@@ -112,9 +120,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||
activeView === 'settings'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
||||
activeView === "settings"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||
}`}
|
||||
title="Open settings"
|
||||
>
|
||||
@@ -135,8 +143,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<div className="p-4 border-b border-term-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-mono font-bold text-term-text">MosaicIQ</h1>
|
||||
<p className="text-[10px] text-term-text-tertiary font-mono">Financial Terminal v1.0</p>
|
||||
<h1 className="text-lg font-mono font-bold text-term-text">
|
||||
MosaicIQ
|
||||
</h1>
|
||||
<p className="text-[10px] text-term-text-tertiary font-mono">
|
||||
Financial Terminal v1.0
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
@@ -153,12 +165,15 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
{/* Portfolio Summary */}
|
||||
<PortfolioSummary
|
||||
portfolio={portfolio}
|
||||
onLoadPortfolio={() => onCommand('/portfolio')}
|
||||
onLoadPortfolio={() => onCommand("/portfolio")}
|
||||
/>
|
||||
|
||||
{/* Ticker History - shows only when loaded */}
|
||||
{isTickerHistoryLoaded && (
|
||||
<TickerHistory history={tickerHistory} onTickerClick={handleCompanyClick} />
|
||||
<TickerHistory
|
||||
history={tickerHistory}
|
||||
onTickerClick={handleCompanyClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -168,42 +183,46 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<button
|
||||
onClick={onOpenTerminal}
|
||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||
activeView === 'terminal'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
||||
activeView === "terminal"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
{/* <TerminalSquare className="h-4 w-4" /> */}
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenResearch}
|
||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||
activeView === 'research'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
||||
activeView === "research"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4" />
|
||||
{/* <NotebookPen className="h-4 w-4" /> */}
|
||||
<span>Research</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||
activeView === 'settings'
|
||||
? 'border-info bg-info/10 text-info'
|
||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
||||
activeView === "settings"
|
||||
? "border-info bg-info/10 text-info"
|
||||
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||
}`}
|
||||
title="Open settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{/* <Settings className="h-4 w-4" /> */}
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] text-term-text-tertiary font-mono text-right">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">Cmd+B</kbd> to toggle
|
||||
Press{" "}
|
||||
<kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">
|
||||
Cmd+B
|
||||
</kbd>{" "}
|
||||
to toggle
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,7 +408,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
||||
|
||||
<div className="relative">
|
||||
{!actionComposerActive && shadowState ? (
|
||||
<div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-hidden rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]">
|
||||
<div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-visible rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]">
|
||||
<div className="flex items-center justify-between border-b border-term-border-subtle bg-term-surface px-3 py-2">
|
||||
<span className="text-[9px] font-mono uppercase tracking-wider text-term-text-muted">
|
||||
Command Shadow
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import { ResearchCaptureModal } from '../Research/ResearchCaptureModal';
|
||||
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
||||
import { extractResearchContext } from '../../lib/researchContext';
|
||||
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
|
||||
import {
|
||||
ResearchNavigationIntent,
|
||||
resolveTerminalCaptureWorkspaceId,
|
||||
resolveTerminalResearchCaptureIntent,
|
||||
} from '../../lib/terminalResearchCapture';
|
||||
import {
|
||||
NoteCaptureResult,
|
||||
ResearchWorkspace,
|
||||
} from '../../types/research';
|
||||
import type { ResearchComposerState } from '../../hooks/useResearchComposer';
|
||||
import {
|
||||
PortfolioAction,
|
||||
PortfolioActionDraft,
|
||||
@@ -29,7 +34,14 @@ interface TerminalProps {
|
||||
onClearPortfolioAction: () => void;
|
||||
resetCommandIndex: () => void;
|
||||
portfolioWorkflow: PortfolioWorkflowState;
|
||||
onOpenResearchContext: (intent: ResearchNavigationIntent) => void;
|
||||
researchWorkspaces: ResearchWorkspace[];
|
||||
activeResearchWorkspaceId: string | null;
|
||||
onCaptureResearchNote: (args: {
|
||||
draft: ResearchComposerState;
|
||||
fallbackTicker?: string;
|
||||
explicitWorkspaceId?: string | null;
|
||||
autoCreateFromTicker?: boolean;
|
||||
}) => Promise<NoteCaptureResult>;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({
|
||||
@@ -44,66 +56,56 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
onClearPortfolioAction,
|
||||
resetCommandIndex,
|
||||
portfolioWorkflow,
|
||||
onOpenResearchContext,
|
||||
researchWorkspaces,
|
||||
activeResearchWorkspaceId,
|
||||
onCaptureResearchNote,
|
||||
}) => {
|
||||
const researchContext = extractResearchContext(history);
|
||||
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
|
||||
const [terminalCapture, setTerminalCapture] = React.useState<{
|
||||
key: string;
|
||||
rawText?: string;
|
||||
ticker?: string;
|
||||
contextLabel: string;
|
||||
contextLabel?: string;
|
||||
} | null>(null);
|
||||
|
||||
const focusResearchCapture = React.useCallback(() => {
|
||||
const defaultWorkspaceId = React.useMemo(
|
||||
() =>
|
||||
resolveTerminalCaptureWorkspaceId({
|
||||
ticker: terminalCapture?.ticker,
|
||||
workspaces: researchWorkspaces,
|
||||
activeWorkspaceId: activeResearchWorkspaceId,
|
||||
}),
|
||||
[activeResearchWorkspaceId, researchWorkspaces, terminalCapture?.ticker],
|
||||
);
|
||||
|
||||
const closeTerminalCapture = React.useCallback(() => {
|
||||
setTerminalCapture(null);
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById('research-capture-input');
|
||||
if (element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
}
|
||||
inputRef.current?.focusWithText('');
|
||||
});
|
||||
}, []);
|
||||
}, [inputRef]);
|
||||
|
||||
const handleTerminalSubmit = React.useCallback(
|
||||
(command: string) => {
|
||||
const normalized = command.trim().toLowerCase();
|
||||
if (normalized === '/notes add') {
|
||||
setTerminalNoteSeed({
|
||||
key: `${Date.now()}-note-add`,
|
||||
ticker: researchContext?.ticker,
|
||||
contextLabel: researchContext?.label ?? 'Quick note from terminal',
|
||||
rawText: '',
|
||||
const researchIntent = resolveTerminalResearchCaptureIntent(command, history);
|
||||
if (researchIntent?.terminalNoteSeed) {
|
||||
setTerminalCapture({
|
||||
key: `${Date.now()}-${command.trim().toLowerCase()}`,
|
||||
rawText: researchIntent.terminalNoteSeed.rawText,
|
||||
ticker: researchIntent.terminalNoteSeed.ticker ?? researchIntent.ticker,
|
||||
contextLabel: researchIntent.terminalNoteSeed.contextLabel,
|
||||
});
|
||||
focusResearchCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized === '/notes current') {
|
||||
const seed = buildTerminalResearchNoteSeed(history);
|
||||
setTerminalNoteSeed({
|
||||
key: `${Date.now()}-note-current`,
|
||||
ticker: seed?.ticker ?? researchContext?.ticker,
|
||||
contextLabel: seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
|
||||
rawText:
|
||||
seed?.rawText ??
|
||||
'Current terminal context is not available yet.\n\nAnalyst note:',
|
||||
});
|
||||
focusResearchCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(command);
|
||||
},
|
||||
[focusResearchCapture, history, onSubmit, researchContext?.label, researchContext?.ticker],
|
||||
[history, onSubmit],
|
||||
);
|
||||
|
||||
const captureContextLabel = terminalNoteSeed?.contextLabel ?? researchContext?.label;
|
||||
const captureTicker = terminalNoteSeed?.ticker ?? researchContext?.ticker;
|
||||
const showResearchCapture = Boolean(terminalNoteSeed);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-w-0 flex-col overflow-hidden bg-term-bg">
|
||||
{/* Command Input */}
|
||||
<div className="relative z-30 flex-shrink-0 overflow-visible border-b border-term-border bg-term-bg p-6">
|
||||
<div className="relative z-30 flex-shrink-0 border-b border-term-border bg-term-bg p-6" style={{ overflow: 'visible' }}>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
onSubmit={handleTerminalSubmit}
|
||||
@@ -117,43 +119,6 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
portfolioDraft={portfolioWorkflow.draft}
|
||||
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||
/>
|
||||
{showResearchCapture ? (
|
||||
<div className="mt-4 flex items-center gap-3 px-4 py-3 bg-term-highlight border border-info rounded">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-mono text-term-text">
|
||||
Research capture ready - click below to open capture modal
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-term-text-tertiary mt-0.5">
|
||||
{captureContextLabel} {captureTicker ? `• ${captureTicker}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTerminalNoteSeed(null)}
|
||||
className="px-3 py-1.5 text-xs font-mono text-term-text-tertiary hover:text-term-text transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenResearchContext({
|
||||
preferredView: undefined,
|
||||
terminalNoteSeed: {
|
||||
rawText: terminalNoteSeed?.rawText,
|
||||
ticker: captureTicker,
|
||||
},
|
||||
});
|
||||
setTerminalNoteSeed(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-mono bg-info hover:brightness-110 text-term-bg rounded font-semibold transition-colors"
|
||||
>
|
||||
Open Capture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Terminal Output */}
|
||||
@@ -163,6 +128,24 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
onRunCommand={onRunCommand}
|
||||
onStartPortfolioAction={onStartPortfolioAction}
|
||||
/>
|
||||
|
||||
<ResearchCaptureModal
|
||||
isOpen={terminalCapture !== null}
|
||||
onClose={closeTerminalCapture}
|
||||
workspaces={researchWorkspaces}
|
||||
defaultWorkspaceId={defaultWorkspaceId}
|
||||
defaultTicker={terminalCapture?.ticker}
|
||||
defaultRawText={terminalCapture?.rawText}
|
||||
seedKey={terminalCapture?.key}
|
||||
contextLabel={terminalCapture?.contextLabel ?? 'Quick capture'}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
draft,
|
||||
fallbackTicker: terminalCapture?.ticker,
|
||||
autoCreateFromTicker: Boolean(terminalCapture?.ticker),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -29,6 +30,7 @@ interface ProjectionState {
|
||||
}
|
||||
|
||||
type ProjectionAction =
|
||||
| { type: 'reset' }
|
||||
| { type: 'load_started'; refresh: boolean }
|
||||
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
||||
| { type: 'load_failed'; error: string }
|
||||
@@ -50,6 +52,8 @@ const projectionReducer = (
|
||||
action: ProjectionAction,
|
||||
): ProjectionState => {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return createProjectionState();
|
||||
case 'load_started':
|
||||
return {
|
||||
...state,
|
||||
@@ -113,31 +117,51 @@ export const useResearchProjection = (
|
||||
const [state, dispatch] = useReducer(projectionReducer, undefined, createProjectionState);
|
||||
const deferredView = useDeferredValue(view);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const previousWorkspaceIdRef = useRef<string | null>(workspaceId);
|
||||
|
||||
const loadProjection = useRef(async (refresh: boolean) => {
|
||||
if (!workspaceId) {
|
||||
const loadProjection = useEffectEvent(async (refresh: boolean) => {
|
||||
const nextWorkspaceId = workspaceId;
|
||||
if (!nextWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
dispatch({ type: 'load_started', refresh });
|
||||
try {
|
||||
const projection = await researchBridge.getWorkspaceProjection({
|
||||
workspaceId,
|
||||
workspaceId: nextWorkspaceId,
|
||||
view: deferredView,
|
||||
});
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'load_succeeded', projection });
|
||||
} catch (loadError) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: 'load_failed',
|
||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
||||
});
|
||||
}
|
||||
}).current;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
previousWorkspaceIdRef.current = null;
|
||||
requestIdRef.current += 1;
|
||||
dispatch({ type: 'reset' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousWorkspaceIdRef.current !== workspaceId) {
|
||||
previousWorkspaceIdRef.current = workspaceId;
|
||||
requestIdRef.current += 1;
|
||||
dispatch({ type: 'reset' });
|
||||
}
|
||||
|
||||
void loadProjection(false);
|
||||
}, [deferredView, loadProjection, workspaceId]);
|
||||
|
||||
@@ -149,7 +173,7 @@ export const useResearchProjection = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useRef(() => {
|
||||
const scheduleRefresh = useEffectEvent(() => {
|
||||
if (refreshTimeoutRef.current != null) {
|
||||
window.clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
@@ -158,7 +182,7 @@ export const useResearchProjection = (
|
||||
void loadProjection(true);
|
||||
refreshTimeoutRef.current = null;
|
||||
}, 150);
|
||||
}).current;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useResearchWorkspace = ({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setWorkspace(null);
|
||||
setIsLoadingWorkspace(true);
|
||||
void researchBridge.getResearchWorkspace(workspaceId).then((nextWorkspace) => {
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -47,7 +47,11 @@ export const useResearchWorkspaces = ({
|
||||
const next = await researchBridge.listResearchWorkspaces();
|
||||
startTransition(() => {
|
||||
setWorkspaces(next);
|
||||
setActiveWorkspaceId((current) => current ?? next[0]?.id ?? null);
|
||||
// Always set activeWorkspaceId if it's null, even if it was previously set
|
||||
setActiveWorkspaceId((current) => {
|
||||
if (current) return current;
|
||||
return next[0]?.id ?? null;
|
||||
});
|
||||
});
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
@@ -60,11 +64,28 @@ export const useResearchWorkspaces = ({
|
||||
void loadWorkspaces();
|
||||
}, [loadWorkspaces]);
|
||||
|
||||
// Ensure activeWorkspaceId always points to a valid workspace
|
||||
useEffect(() => {
|
||||
if (activeWorkspaceId) {
|
||||
const exists = workspaces.find((w) => w.id === activeWorkspaceId);
|
||||
if (!exists && workspaces.length > 0) {
|
||||
startTransition(() => {
|
||||
setActiveWorkspaceId(workspaces[0].id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeWorkspaceId, workspaces]);
|
||||
|
||||
useResearchEventSubscriptions({
|
||||
onWorkspaceUpdate: ({ workspace }) => {
|
||||
startTransition(() => {
|
||||
setWorkspaces((current) => upsertWorkspaceRecord(current, workspace));
|
||||
setActiveWorkspaceId((current) => current ?? workspace.id);
|
||||
setActiveWorkspaceId((current) => {
|
||||
// If we have a current active workspace, keep it
|
||||
if (current) return current;
|
||||
// Otherwise, use the updated workspace's ID
|
||||
return workspace.id;
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
124
MosaicIQ/src/lib/terminalResearchCapture.test.ts
Normal file
124
MosaicIQ/src/lib/terminalResearchCapture.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
resolveTerminalCaptureWorkspaceId,
|
||||
resolveTerminalResearchCaptureIntent,
|
||||
} from './terminalResearchCapture';
|
||||
import type { TerminalEntry } from '../types/terminal';
|
||||
import type { ResearchWorkspace } from '../types/research';
|
||||
|
||||
describe('resolveTerminalResearchCaptureIntent', () => {
|
||||
const history: TerminalEntry[] = [
|
||||
{
|
||||
id: 'command-1',
|
||||
type: 'command',
|
||||
content: '/search NVDA',
|
||||
timestamp: new Date('2026-04-09T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'panel-1',
|
||||
type: 'panel',
|
||||
content: {
|
||||
type: 'company',
|
||||
data: {
|
||||
symbol: 'NVDA',
|
||||
name: 'NVIDIA',
|
||||
price: 900,
|
||||
change: 12,
|
||||
changePercent: 1.35,
|
||||
marketCap: 2_000_000_000_000,
|
||||
profile: {
|
||||
sector: 'Semiconductors',
|
||||
description: 'GPU leader.',
|
||||
},
|
||||
},
|
||||
},
|
||||
timestamp: new Date('2026-04-09T10:00:01Z'),
|
||||
},
|
||||
];
|
||||
|
||||
it('creates a direct-open research intent for quick notes', () => {
|
||||
const intent = resolveTerminalResearchCaptureIntent('/notes add', history);
|
||||
|
||||
expect(intent).toEqual({
|
||||
ticker: 'NVDA',
|
||||
terminalNoteSeed: {
|
||||
rawText: '',
|
||||
ticker: 'NVDA',
|
||||
contextLabel: 'NVDA company snapshot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a seeded research intent for current terminal context', () => {
|
||||
const intent = resolveTerminalResearchCaptureIntent('/notes current', history);
|
||||
|
||||
expect(intent?.ticker).toBe('NVDA');
|
||||
expect(intent?.terminalNoteSeed?.ticker).toBe('NVDA');
|
||||
expect(intent?.terminalNoteSeed?.contextLabel).toBe('NVDA company context');
|
||||
expect(intent?.terminalNoteSeed?.rawText).toContain(
|
||||
'Current terminal context: company snapshot for NVDA',
|
||||
);
|
||||
expect(intent?.terminalNoteSeed?.rawText).toContain('Analyst note:');
|
||||
});
|
||||
|
||||
it('prefers a workspace whose primary ticker matches the terminal capture', () => {
|
||||
const workspaces: ResearchWorkspace[] = [
|
||||
{
|
||||
id: 'workspace-aapl',
|
||||
name: 'AAPL Research',
|
||||
primaryTicker: 'AAPL',
|
||||
scope: 'single_company',
|
||||
stage: 'capture',
|
||||
defaultView: 'canvas',
|
||||
pinnedNoteIds: [],
|
||||
archived: false,
|
||||
createdAt: '2026-04-09T10:00:00Z',
|
||||
updatedAt: '2026-04-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'workspace-nvda',
|
||||
name: 'NVDA Research',
|
||||
primaryTicker: 'NVDA',
|
||||
scope: 'single_company',
|
||||
stage: 'capture',
|
||||
defaultView: 'canvas',
|
||||
pinnedNoteIds: [],
|
||||
archived: false,
|
||||
createdAt: '2026-04-09T10:00:00Z',
|
||||
updatedAt: '2026-04-09T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTerminalCaptureWorkspaceId({
|
||||
ticker: 'nvda',
|
||||
workspaces,
|
||||
activeWorkspaceId: 'workspace-aapl',
|
||||
}),
|
||||
).toBe('workspace-nvda');
|
||||
});
|
||||
|
||||
it('falls back to the active workspace when the terminal capture has no ticker', () => {
|
||||
const workspaces: ResearchWorkspace[] = [
|
||||
{
|
||||
id: 'workspace-aapl',
|
||||
name: 'AAPL Research',
|
||||
primaryTicker: 'AAPL',
|
||||
scope: 'single_company',
|
||||
stage: 'capture',
|
||||
defaultView: 'canvas',
|
||||
pinnedNoteIds: [],
|
||||
archived: false,
|
||||
createdAt: '2026-04-09T10:00:00Z',
|
||||
updatedAt: '2026-04-09T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTerminalCaptureWorkspaceId({
|
||||
workspaces,
|
||||
activeWorkspaceId: 'workspace-aapl',
|
||||
}),
|
||||
).toBe('workspace-aapl');
|
||||
});
|
||||
});
|
||||
66
MosaicIQ/src/lib/terminalResearchCapture.ts
Normal file
66
MosaicIQ/src/lib/terminalResearchCapture.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ResearchNavigationIntent, ResearchWorkspace } from '../types/research';
|
||||
import type { TerminalEntry } from '../types/terminal';
|
||||
import { extractResearchContext } from './researchContext';
|
||||
import { buildTerminalResearchNoteSeed } from './terminalResearchNote';
|
||||
|
||||
const FALLBACK_CURRENT_CONTEXT_TEXT =
|
||||
'Current terminal context is not available yet.\n\nAnalyst note:';
|
||||
|
||||
export const resolveTerminalResearchCaptureIntent = (
|
||||
command: string,
|
||||
history: TerminalEntry[],
|
||||
): ResearchNavigationIntent | null => {
|
||||
const normalized = command.trim().toLowerCase();
|
||||
const researchContext = extractResearchContext(history);
|
||||
|
||||
if (normalized === '/notes add') {
|
||||
return {
|
||||
ticker: researchContext?.ticker,
|
||||
terminalNoteSeed: {
|
||||
rawText: '',
|
||||
ticker: researchContext?.ticker,
|
||||
contextLabel: researchContext?.label ?? 'Quick note from terminal',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized === '/notes current') {
|
||||
const seed = buildTerminalResearchNoteSeed(history);
|
||||
const ticker = seed?.ticker ?? researchContext?.ticker;
|
||||
|
||||
return {
|
||||
ticker,
|
||||
terminalNoteSeed: {
|
||||
rawText: seed?.rawText ?? FALLBACK_CURRENT_CONTEXT_TEXT,
|
||||
ticker,
|
||||
contextLabel:
|
||||
seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveTerminalCaptureWorkspaceId = ({
|
||||
ticker,
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
}: {
|
||||
ticker?: string;
|
||||
workspaces: ResearchWorkspace[];
|
||||
activeWorkspaceId?: string | null;
|
||||
}): string | undefined => {
|
||||
const normalizedTicker = ticker?.trim().toUpperCase();
|
||||
if (normalizedTicker) {
|
||||
return workspaces.find(
|
||||
(workspace) => workspace.primaryTicker.trim().toUpperCase() === normalizedTicker,
|
||||
)?.id;
|
||||
}
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
return activeWorkspaceId;
|
||||
}
|
||||
|
||||
return workspaces.length === 1 ? workspaces[0].id : undefined;
|
||||
};
|
||||
@@ -596,6 +596,7 @@ export interface ResearchNavigationIntent {
|
||||
terminalNoteSeed?: {
|
||||
rawText?: string;
|
||||
ticker?: string;
|
||||
contextLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user