(null);
-
// Helper functions to open task forms
const handleCreateTask = () => {
- if (project?.id) {
- openTaskForm({ projectId: project.id });
+ if (projectId) {
+ openTaskForm({ projectId });
}
};
const handleEditTask = (task: Task) => {
- if (project?.id) {
- openTaskForm({ projectId: project.id, task });
+ if (projectId) {
+ openTaskForm({ projectId, task });
}
};
const handleDuplicateTask = (task: Task) => {
- if (project?.id) {
- openTaskForm({ projectId: project.id, initialTask: task });
+ if (projectId) {
+ openTaskForm({ projectId, initialTask: task });
}
};
const { query: searchQuery, focusInput } = useSearch();
@@ -280,17 +286,6 @@ export function ProjectTasks() {
}
);
- // Full screen
-
- const fetchProject = useCallback(async () => {
- try {
- const result = await projectsApi.getById(projectId!);
- setProject(result);
- } catch (err) {
- setError('Failed to load project');
- }
- }, [projectId]);
-
const handleClosePanel = useCallback(() => {
// setIsPanelOpen(false);
// setSelectedTask(null);
@@ -458,26 +453,16 @@ export function ProjectTasks() {
});
// UI will update via WebSocket stream
} catch (err) {
- setError('Failed to update task status');
+ console.error('Failed to update task status:', err);
}
},
[tasksById]
);
- // Initialize project when projectId changes
- useEffect(() => {
- if (projectId) {
- fetchProject();
- }
- }, [projectId, fetchProject]);
+ // Combine loading states for initial load
+ const isInitialTasksLoad = isLoading && tasks.length === 0;
- // Remove legacy direct-navigation handler; live sync above covers this
-
- if (isLoading) {
- return ;
- }
-
- if (error) {
+ if (projectError) {
return (
@@ -485,12 +470,18 @@ export function ProjectTasks() {
{t('common:states.error')}
- {error}
+
+ {projectError.message || 'Failed to load project'}
+
);
}
+ if (projectLoading && isInitialTasksLoad) {
+ return ;
+ }
+
return (
{/* Right Column - Task Details Panel */}
- {isPanelOpen && (
+ {isPanelOpen && !projectLoading && (
at the app root.
+ - Next.js (pages): render in pages/_app.*
+ - Next.js (app): render in app/layout.* or a client providers component.
+ - For Next.js, if SSR issues arise, use dynamic import with ssr: false.
+
+4) Verify:
+ - Type-check, lint/format if configured.
+ - Ensure it compiles and renders without SSR/hydration errors.
+
+Acceptance:
+- vibe-kanban-web-companion is installed in the correct package.
+- The component is rendered once at the app root without SSR/hydration errors.
+- Build/type-check passes.`;
diff --git a/frontend/src/utils/previewBridge.ts b/frontend/src/utils/previewBridge.ts
new file mode 100644
index 00000000..e90d1988
--- /dev/null
+++ b/frontend/src/utils/previewBridge.ts
@@ -0,0 +1,130 @@
+export interface ComponentSource {
+ fileName: string;
+ lineNumber: number;
+ columnNumber: number;
+}
+
+export interface ComponentInfo {
+ name: string;
+ props: Record;
+ source: ComponentSource;
+ pathToSource: string;
+}
+
+export interface SelectedComponent extends ComponentInfo {
+ editor: string;
+ url: string;
+}
+
+export interface ClickedElement {
+ tag?: string;
+ id?: string;
+ className?: string;
+ role?: string;
+ dataset?: Record;
+}
+
+export interface Coordinates {
+ x?: number;
+ y?: number;
+}
+
+export interface OpenInEditorPayload {
+ selected: SelectedComponent;
+ components: ComponentInfo[];
+ trigger: 'alt-click' | 'context-menu';
+ coords?: Coordinates;
+ clickedElement?: ClickedElement;
+}
+
+export interface ClickToComponentMessage {
+ source: 'click-to-component';
+ version: number;
+ type: 'ready' | 'open-in-editor';
+ payload?: OpenInEditorPayload;
+}
+
+export interface EventHandlers {
+ onReady?: () => void;
+ onOpenInEditor?: (payload: OpenInEditorPayload) => void;
+ onUnknownMessage?: (message: unknown) => void;
+}
+
+export class ClickToComponentListener {
+ private handlers: EventHandlers = {};
+ private messageListener: ((event: MessageEvent) => void) | null = null;
+
+ constructor(handlers: EventHandlers = {}) {
+ this.handlers = handlers;
+ }
+
+ /**
+ * Start listening for messages from click-to-component iframe
+ */
+ start(): void {
+ if (this.messageListener) {
+ this.stop(); // Clean up existing listener
+ }
+
+ this.messageListener = (event: MessageEvent) => {
+ const data = event.data as ClickToComponentMessage;
+
+ // Only handle messages from our click-to-component tool
+ if (!data || data.source !== 'click-to-component') {
+ return;
+ }
+
+ switch (data.type) {
+ case 'ready':
+ this.handlers.onReady?.();
+ break;
+
+ case 'open-in-editor':
+ if (data.payload) {
+ this.handlers.onOpenInEditor?.(data.payload);
+ }
+ break;
+
+ default:
+ this.handlers.onUnknownMessage?.(data);
+ }
+ };
+
+ window.addEventListener('message', this.messageListener);
+ }
+
+ /**
+ * Stop listening for messages
+ */
+ stop(): void {
+ if (this.messageListener) {
+ window.removeEventListener('message', this.messageListener);
+ this.messageListener = null;
+ }
+ }
+
+ /**
+ * Update event handlers
+ */
+ setHandlers(handlers: EventHandlers): void {
+ this.handlers = { ...this.handlers, ...handlers };
+ }
+
+ /**
+ * Send a message to the iframe (if needed)
+ */
+ sendToIframe(iframe: HTMLIFrameElement, message: unknown): void {
+ if (iframe.contentWindow) {
+ iframe.contentWindow.postMessage(message, '*');
+ }
+ }
+}
+
+// Convenience function for quick setup
+export function listenToClickToComponent(
+ handlers: EventHandlers
+): ClickToComponentListener {
+ const listener = new ClickToComponentListener(handlers);
+ listener.start();
+ return listener;
+}